import ActivityKit
import AVFAudio
import Combine
import CryptoKit
import Foundation
import os
import SwiftUI
import WebRTC

let callLogger = Logger(subsystem: "com.rosetta.messenger", category: "Call")

@MainActor
final class CallManager: NSObject, ObservableObject {

    static let shared = CallManager()

    @Published var uiState = CallUiState()

    var ownPublicKey: String = ""
    var role: CallRole?
    var signalingMode: CallSignalingMode = .undecided
    var roomId: String = ""
    var callId: String = ""
    var joinToken: String = ""
    var localPrivateKey: Curve25519.KeyAgreement.PrivateKey?
    var localPublicKeyHex: String = ""
    var sharedKey: Data?
    var offerSent = false
    var remoteDescriptionSet = false
    var lastPeerSharedPublicHex = ""

    var iceServers: [RTCIceServer] = []

    private var signalToken: UUID?
    private var webRtcToken: UUID?
    private var iceToken: UUID?

    var peerConnectionFactory: RTCPeerConnectionFactory?
    var peerConnection: RTCPeerConnection?
    var localAudioSource: RTCAudioSource?
    var localAudioTrack: RTCAudioTrack?
    var localAudioSender: RTCRtpSender?
    var bufferedRemoteCandidates: [RTCIceCandidate] = []
    var attachedReceiverIds: Set<String> = []

    var durationTask: Task<Void, Never>?
    var ringTimeoutTask: Task<Void, Never>?
    var pendingMinimizeTask: Task<Void, Never>?
    var liveActivity: Activity<CallActivityAttributes>?
    /// Re-entrancy guard: prevents CXEndCallAction → endCall() → finishCall() loop.
    var isFinishingCall = false
    /// Pending accept: user tapped Accept on CallKit before WebSocket delivered .call signal.
    /// When handleSignalPacket(.call) arrives, auto-accept if this is true.
    var pendingCallKitAccept = false
    /// Deferred incoming accept: user accepted, but call signaling metadata/mode is
    /// not yet sufficient to send the next signal (legacy ACCEPT or create-room KEY_EXCHANGE).
    var pendingIncomingAccept = false
    /// True after CallKit fires didActivate — audio session has the entitlement.
    /// WebRTC peer connection MUST NOT be created before this flag is true,
    /// otherwise AURemoteIO init fails with "Missing entitlement" (-12988).
    var audioSessionActivated = false
    /// True after CallKit fires didDeactivate. Prevents double-deactivation in
    /// deactivateAudioSession() — calling setActive(false) after CallKit already
    /// deactivated decrements WebRTC's internal counter below zero, breaking
    /// audio on the next call.
    var didReceiveCallKitDeactivation = false
    /// Buffered when .createRoom arrives before didActivate. Flushed in onAudioSessionActivated().
    var pendingWebRtcSetup = false
    /// Buffers WebRTC packets (OFFER/ANSWER/ICE) from SFU that arrive before
    /// peerConnection is created (CallKit didActivate delay). Without this, packets
    /// are silently dropped. Android doesn't need this — no CallKit.
    var bufferedWebRtcPackets: [PacketWebRTC] = []
    /// Recovery timer for ICE .disconnected state. Android waits 15s before ending call;
    /// iOS was killing instantly on any brief network hiccup.
    var disconnectRecoveryTask: Task<Void, Never>?
    /// Media readiness gate for transitioning to `.active`.
    /// We only start call timer after transport is connected and remote audio path exists.
    var hasConnectedTransport = false
    var hasRemoteAudioTrack = false
    /// Periodic E2EE rebind loop (1.5s, Android parity). SFU may create new
    /// RTP senders/receivers during renegotiation that need encryptor/decryptor attachment.
    var e2eeRebindTask: Task<Void, Never>?

    private override init() {
        super.init()
        // Tell WebRTC to NOT auto-activate audio session when audio tracks are created.
        // Audio will be enabled only after CallKit's didActivate callback fires.
        // Without this, WebRTC calls AVAudioSession.setActive(true) before CallKit
        // grants the entitlement → -12988 "Missing entitlement" → no audio on first call.
        let rtcSession = RTCAudioSession.sharedInstance()
        rtcSession.useManualAudio = true
        rtcSession.isAudioEnabled = false
        wireProtocolHandlers()
    }

    deinit {
        if let signalToken { ProtocolManager.shared.removeSignalPeerHandler(signalToken) }
        if let webRtcToken { ProtocolManager.shared.removeWebRtcHandler(webRtcToken) }
        if let iceToken { ProtocolManager.shared.removeIceServersHandler(iceToken) }
    }

    // MARK: - Public API

    func bindAccount(publicKey: String) {
        ownPublicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// Sets up incoming call state directly from VoIP push payload.
    /// Called when app was killed → PushKit wakes it → WebSocket not yet connected.
    /// The .call signal may never arrive (fire-and-forget), so we set up state from push.
    func setupIncomingCallFromPush(
        callerKey: String,
        callerName: String,
        callId: String? = nil,
        joinToken: String? = nil
    ) {
        let peer = callerKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !peer.isEmpty else { return }
        mergeLegacySessionMetadata(callId: callId, joinToken: joinToken, source: "push")
        if hasLegacySessionMetadata {
            setSignalingMode(.legacy, reason: "push metadata")
        }
        // If callId present but joinToken missing (VoIPApns.java:57-67 omits joinToken),
        // keep mode as .undecided. The WebSocket CALL signal will deliver full metadata.
        // Android parity: polling loop in completePendingIncomingAcceptIfPossible waits
        // for metadata and sends ACCEPT (never KEY_EXCHANGE for callee).

        guard uiState.phase == .idle else {
            completePendingIncomingAcceptIfPossible(trigger: "push update")
            return
        }

        callLogger.notice(
            "setupIncomingCallFromPush: callerKey=\(peer.prefix(12), privacy: .public) name=\(callerName, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public) callId=\(callId ?? "nil", privacy: .public) joinToken=\((joinToken?.isEmpty == false).description, privacy: .public) ownKey=\(self.ownPublicKey.isEmpty ? "EMPTY" : String(self.ownPublicKey.prefix(12)), privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)"
        )
        // Don't call beginCallSession() — it calls finishCall() which kills the
        // CallKit call that PushKit just reported. Set state directly instead.
        uiState = CallUiState(
            phase: .incoming,
            peerPublicKey: peer,
            peerTitle: callerName,
            peerUsername: ""
        )
        role = .callee
        uiState.statusText = "Incoming call..."
        ProtocolManager.shared.beginCallBackgroundTask()
        hydratePeerIdentity(for: peer)
        startRingTimeout()

        // Auto-accept if user already tapped Accept on CallKit before this ran.
        // Happens when app was killed → VoIP push → CallKit → Accept → WebSocket
        // not yet connected, so CXAnswerCallAction fired before phase was .incoming.
        if pendingCallKitAccept {
            pendingCallKitAccept = false
            callLogger.notice("setupIncomingCallFromPush: auto-accepting (pendingCallKitAccept)")
            let result = acceptIncomingCall()
            callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)")
        }
        completePendingIncomingAcceptIfPossible(trigger: "push setup")
    }

    func onAuthenticated() {
        ProtocolManager.shared.requestIceServers()
    }

    func resetForSessionEnd() {
        finishCall(reason: nil, notifyPeer: false)
    }

    func startOutgoingCall(toPublicKey: String, title: String, username: String) -> CallActionResult {
        let target = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !target.isEmpty, !DatabaseManager.isGroupDialogKey(target) else { return .invalidTarget }
        guard ownPublicKey.isEmpty == false else { return .accountNotBound }
        guard uiState.phase == .idle else { return .alreadyInCall }

        beginCallSession(peerPublicKey: target, title: title, username: username)
        role = .caller
        setSignalingMode(.undecided, reason: "outgoing call started")
        ensureLocalSessionKeys()
        uiState.phase = .outgoing
        uiState.statusText = "Calling..."

        ProtocolManager.shared.beginCallBackgroundTask()
        CallKitManager.shared.startOutgoingCall(peerKey: target, displayName: uiState.displayName)

        ProtocolManager.shared.sendCallSignal(
            signalType: .call,
            src: ownPublicKey,
            dst: target
        )
        CallSoundManager.shared.playCalling()
        startRingTimeout()
        startLiveActivity()
        return .started
    }

    func acceptIncomingCall() -> CallActionResult {
        guard uiState.phase == .incoming else { return .notIncoming }
        guard ownPublicKey.isEmpty == false else { return .accountNotBound }
        guard uiState.peerPublicKey.isEmpty == false else { return .invalidTarget }

        cancelRingTimeout()
        CallSoundManager.shared.stopAll()
        role = .callee
        ensureLocalSessionKeys()
        guard localPublicKeyHex.isEmpty == false else { return .invalidTarget }

        pendingIncomingAccept = true
        uiState.phase = .keyExchange
        uiState.isMinimized = false  // Show full-screen custom overlay after accept
        uiState.statusText = "Connecting..."
        callLogger.notice(
            "[Call] acceptIncomingCall: queued mode=\(self.signalingMode.rawValue, privacy: .public) hasMetadata=\(self.hasLegacySessionMetadata.description, privacy: .public) audioActivated=\(self.audioSessionActivated.description, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)"
        )
        completePendingIncomingAcceptIfPossible(trigger: "acceptIncomingCall")
        return .started
    }

    func declineIncomingCall() {
        callLogger.info("[Call] declineIncomingCall phase=\(self.uiState.phase.rawValue, privacy: .public)")
        guard uiState.phase == .incoming else { return }
        pendingCallKitAccept = false
        if ownPublicKey.isEmpty == false, uiState.peerPublicKey.isEmpty == false {
            ProtocolManager.shared.sendCallSignal(
                signalType: .endCall,
                src: ownPublicKey,
                dst: uiState.peerPublicKey,
                callId: callId,
                joinToken: joinToken
            )
        }
        finishCall(reason: nil, notifyPeer: false)
    }

    func endCall() {
        callLogger.notice("[Call] endCall phase=\(self.uiState.phase.rawValue, privacy: .public)")
        finishCall(reason: nil, notifyPeer: true)
    }

    func toggleMute() {
        let nextMuted = !uiState.isMuted
        uiState.isMuted = nextMuted
        localAudioTrack?.isEnabled = !nextMuted
        CallKitManager.shared.setMuted(nextMuted)
        updateLiveActivity()
        callLogger.info("[Call] toggleMute isMuted=\(nextMuted.description, privacy: .public)")
    }

    func toggleSpeaker() {
        let nextSpeaker = !uiState.isSpeakerOn
        uiState.isSpeakerOn = nextSpeaker
        applyAudioOutputRouting()
        let route = AVAudioSession.sharedInstance().currentRoute
        callLogger.info("[Call] toggleSpeaker isSpeakerOn=\(nextSpeaker.description, privacy: .public)")
    }

    func minimizeCall() {
        guard uiState.isVisible else { return }
        pendingMinimizeTask?.cancel()
        pendingMinimizeTask = nil
        callLogger.info("[Call] minimizeCall phase=\(self.uiState.phase.rawValue, privacy: .public)")
        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
            uiState.isMinimized = true
        }
    }

    func expandCall() {
        pendingMinimizeTask?.cancel()
        pendingMinimizeTask = nil
        guard uiState.isVisible else { return }
        callLogger.info("[Call] expandCall phase=\(self.uiState.phase.rawValue, privacy: .public)")
        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
            uiState.isMinimized = false
        }
    }

    // MARK: - Protocol handlers

    private func wireProtocolHandlers() {
        // Priority .userInitiated — call signals MUST NOT wait behind sync batch.
        // During background wake-up, sync floods MainActor with hundreds of message
        // Tasks (default priority). Without elevated priority, call signal Tasks
        // queue behind sync → keyExchange delayed → Desktop gives up → call fails.
        signalToken = ProtocolManager.shared.addSignalPeerHandler { [weak self] packet in
            Task(priority: .userInitiated) { @MainActor [weak self] in
                self?.handleSignalPacket(packet)
            }
        }
        webRtcToken = ProtocolManager.shared.addWebRtcHandler { [weak self] packet in
            Task(priority: .userInitiated) { @MainActor [weak self] in
                await self?.handleWebRtcPacket(packet)
            }
        }
        iceToken = ProtocolManager.shared.addIceServersHandler { [weak self] packet in
            Task(priority: .userInitiated) { @MainActor [weak self] in
                self?.handleIceServersPacket(packet)
            }
        }
    }

    private var hasLegacySessionMetadata: Bool {
        !callId.isEmpty && !joinToken.isEmpty
    }

    private func setSignalingMode(_ mode: CallSignalingMode, reason: String) {
        guard signalingMode != mode else { return }
        callLogger.notice(
            "[Call] signaling mode \(self.signalingMode.rawValue, privacy: .public) -> \(mode.rawValue, privacy: .public) (\(reason, privacy: .public))"
        )
        signalingMode = mode
    }

    private func mergeLegacySessionMetadata(callId: String?, joinToken: String?, source: String) {
        let normalizedCallId = callId?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        let normalizedJoinToken = joinToken?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

        if !normalizedCallId.isEmpty && self.callId != normalizedCallId {
            callLogger.info(
                "[Call] merge callId from \(source, privacy: .public): \(normalizedCallId.prefix(12), privacy: .public)"
            )
            self.callId = normalizedCallId
        }
        if !normalizedJoinToken.isEmpty && self.joinToken != normalizedJoinToken {
            callLogger.info("[Call] merge joinToken from \(source, privacy: .public)")
            self.joinToken = normalizedJoinToken
        }
    }

    private func completePendingIncomingAcceptIfPossible(trigger: String) {
        guard pendingIncomingAccept else { return }
        guard ownPublicKey.isEmpty == false else { return }
        guard uiState.peerPublicKey.isEmpty == false else { return }

        switch signalingMode {
        case .legacy:
            guard hasLegacySessionMetadata else {
                callLogger.info("[Call] pending accept waits for legacy metadata (\(trigger, privacy: .public))")
                return
            }
            ProtocolManager.shared.sendCallSignal(
                signalType: .accept,
                src: ownPublicKey,
                dst: uiState.peerPublicKey,
                callId: callId,
                joinToken: joinToken
            )
            pendingIncomingAccept = false
            uiState.statusText = "Waiting for key exchange..."
            callLogger.notice("[Call] pending accept completed via ACCEPT (\(trigger, privacy: .public))")

        case .createRoom:
            ensureLocalSessionKeys()
            guard !localPublicKeyHex.isEmpty else { return }
            ProtocolManager.shared.sendCallSignal(
                signalType: .keyExchange,
                src: ownPublicKey,
                dst: uiState.peerPublicKey,
                sharedPublic: localPublicKeyHex
            )
            pendingIncomingAccept = false
            uiState.statusText = "Exchanging keys..."
            callLogger.notice("[Call] pending accept completed via KEY_EXCHANGE (\(trigger, privacy: .public))")

        case .undecided:
            callLogger.info("[Call] pending accept: mode undecided, waiting for metadata (\(trigger, privacy: .public))")
            // Wait for metadata — from VoIP push (now includes joinToken after server fix)
            // or WebSocket CALL signal. 5-second timeout prevents infinite hang.
            Task { @MainActor [weak self] in
                try? await Task.sleep(for: .seconds(5))
                guard let self, self.pendingIncomingAccept, self.signalingMode == .undecided else { return }
                callLogger.error("[Call] undecided timeout (5s) — no metadata arrived")
                self.finishCall(reason: "Connection timeout", notifyPeer: false)
            }
        }
    }

    private func handleSignalPacket(_ packet: PacketSignalPeer) {
        callLogger.notice(
            "[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)"
        )
        switch packet.signalType {
        case .endCallBecauseBusy:
            // Android parity: notifyPeer=false. Server does NOT create a CallSession
            // when callee is busy (checks isBusy BEFORE createCall), so sending endCall
            // back would hit NO_CALL_SESSION → server disconnects our WebSocket.
            finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
            return
        case .endCallBecausePeerDisconnected:
            finishCall(reason: "Peer disconnected", notifyPeer: false)
            return
        case .ringingTimeout:
            finishCall(reason: "No answer", notifyPeer: false, skipAttachment: true)
            return
        case .endCall:
            finishCall(reason: "Call ended", notifyPeer: false)
            return
        default:
            break
        }

        if uiState.peerPublicKey.isEmpty == false, packet.src.isEmpty == false {
            if packet.src != uiState.peerPublicKey && packet.src != ownPublicKey {
                return
            }
        }

        switch packet.signalType {
        case .call:
            let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines)
            guard incomingPeer.isEmpty == false else { return }
            mergeLegacySessionMetadata(
                callId: packet.callId,
                joinToken: packet.joinToken,
                source: "signal.call"
            )
            if hasLegacySessionMetadata {
                setSignalingMode(.legacy, reason: "incoming .call with call metadata")
            } else if signalingMode == .undecided {
                callLogger.warning("[Call] WebSocket .call signal has no metadata — keeping undecided")
            }
            guard uiState.phase == .idle else {
                // Already in a call with this peer — ignore duplicate .call signal.
                // Server re-delivers .call after WebSocket reconnect; without this guard,
                // the code sends .endCallBecauseBusy which terminates the active call.
                if incomingPeer == uiState.peerPublicKey {
                    callLogger.info("Ignoring duplicate .call signal — already in call with this peer (phase=\(self.uiState.phase.rawValue, privacy: .public))")
                    completePendingIncomingAcceptIfPossible(trigger: "duplicate .call")
                    return
                }
                // Different peer trying to call — send busy.
                ProtocolManager.shared.sendCallSignal(
                    signalType: .endCallBecauseBusy,
                    src: ownPublicKey,
                    dst: incomingPeer
                )
                return
            }
            let cachedCallId = callId
            let cachedJoinToken = joinToken
            // Resolve name from DialogRepository BEFORE beginCallSession resets state.
            // Without this, CallKit shows the public key prefix instead of the name.
            let cachedDialog = DialogRepository.shared.dialogs[incomingPeer]
            var cachedTitle = cachedDialog?.opponentTitle ?? ""
            let cachedUsername = cachedDialog?.opponentUsername ?? ""
            // Fallback: after cold start from VoIP push, DialogRepository may not
            // be loaded yet. Check UserDefaults contact_display_names (synced by
            // DialogRepository.syncContactNamesToDefaults) — same source the VoIP
            // push handler uses for name resolution.
            if cachedTitle.isEmpty, cachedUsername.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[incomingPeer], !name.isEmpty {
                        cachedTitle = name
                        break
                    }
                }
            }
            beginCallSession(peerPublicKey: incomingPeer, title: cachedTitle, username: cachedUsername)
            mergeLegacySessionMetadata(
                callId: packet.callId,
                joinToken: packet.joinToken,
                source: "signal.call post-reset"
            )
            if !hasLegacySessionMetadata {
                mergeLegacySessionMetadata(
                    callId: cachedCallId,
                    joinToken: cachedJoinToken,
                    source: "signal.call cached metadata"
                )
            }
            if hasLegacySessionMetadata {
                setSignalingMode(.legacy, reason: "incoming .call session start")
            } else {
                callLogger.warning("[Call] .call signal has no metadata — mode stays undecided")
            }
            role = .callee
            uiState.phase = .incoming
            ProtocolManager.shared.beginCallBackgroundTask()
            withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
                uiState.isMinimized = false
            }
            uiState.statusText = "Incoming call..."
            hydratePeerIdentity(for: incomingPeer)
            // Always report to CallKit (foreground = compact banner, background = full screen).
            // Custom ActiveCallOverlayView only appears AFTER user accepts (phase != .incoming).
            // Telegram parity: CallKit handles incoming UI, custom UI handles active call.
            if CallKitManager.shared.currentCallUUID == nil,
               !CallKitManager.shared.hasPendingCall() {
                CallKitManager.shared.reportIncomingCall(
                    callerKey: incomingPeer,
                    callerName: uiState.displayName
                )
            }
            // No playRingtone / startLiveActivity — CallKit handles ringtone and Dynamic Island.
            startRingTimeout()

            // Auto-accept if user already tapped Accept on CallKit before WebSocket connected.
            // This happens when app was killed → VoIP push → CallKit → Accept → WebSocket reconnects.
            if pendingCallKitAccept {
                pendingCallKitAccept = false
                let result = acceptIncomingCall()
                callLogger.info("Auto-accept: result=\(String(describing: result), privacy: .public) ownKey=\(self.ownPublicKey.isEmpty ? "EMPTY" : String(self.ownPublicKey.prefix(12)), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)")
            }
            completePendingIncomingAcceptIfPossible(trigger: "incoming .call")
        case .accept:
            mergeLegacySessionMetadata(
                callId: packet.callId,
                joinToken: packet.joinToken,
                source: "signal.accept"
            )
            guard role == .caller else { return }
            if signalingMode == .undecided {
                setSignalingMode(.legacy, reason: "caller received ACCEPT")
            } else if signalingMode == .createRoom {
                callLogger.notice("[Call] ACCEPT ignored — mode already createRoom")
                return
            }
            ensureLocalSessionKeys()
            guard !localPublicKeyHex.isEmpty else { return }
            ProtocolManager.shared.sendCallSignal(
                signalType: .keyExchange,
                src: ownPublicKey,
                dst: uiState.peerPublicKey,
                sharedPublic: localPublicKeyHex
            )
            uiState.phase = .keyExchange
            uiState.statusText = "Exchanging keys..."
            callLogger.notice("[Call] legacy path: ACCEPT -> KEY_EXCHANGE")
        case .keyExchange:
            if role == .caller, signalingMode == .undecided {
                setSignalingMode(.createRoom, reason: "caller received KEY_EXCHANGE before ACCEPT")
            }
            handleKeyExchange(packet)
        case .createRoom:
            let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines)
            if incomingRoomId.isEmpty {
                if signalingMode == .undecided {
                    setSignalingMode(.legacy, reason: "signal 4 without roomId")
                }
                callLogger.notice("[Call] signal 4 handled as ACTIVE (legacy)")
            } else {
                roomId = incomingRoomId
                if signalingMode == .undecided {
                    setSignalingMode(.createRoom, reason: "signal 4 with roomId")
                }
                callLogger.notice("[Call] signal 4 handled as CREATE_ROOM roomId=\(incomingRoomId.prefix(16), privacy: .public)")
            }
            uiState.phase = .webRtcExchange
            uiState.statusText = "Connecting..."
            // Create peer connection IMMEDIATELY — don't wait for didActivate.
            // With useManualAudio=true, WebRTC does NOT create AudioUnit on
            // peer connection creation. AudioUnit is created lazily when
            // isAudioEnabled becomes true. So SDP/ICE can proceed in parallel
            // with audio session activation.
            Task(priority: .userInitiated) { [weak self] in
                await self?.ensurePeerConnectionAndOffer()
            }
            if !audioSessionActivated {
                pendingWebRtcSetup = true
                callLogger.notice("[Call] Deferring audio enable — waiting for CallKit didActivate")
                // Telegram parity: just wait for didActivate. The audio session
                // is pre-configured in the VoIP push handler (setCategory .playAndRecord
                // + .voiceChat BEFORE reportNewIncomingCall), so CallKit should
                // deliver didActivate reliably. Fallback after 8s if it doesn't.
                Task(priority: .userInitiated) { @MainActor [weak self] in
                    try? await Task.sleep(for: .seconds(8))
                    guard let self, !self.audioSessionActivated, self.uiState.phase != .idle else { return }
                    callLogger.warning("[Call] didActivate timeout (8s) — force-enabling audio")
                    let rtcSession = RTCAudioSession.sharedInstance()
                    let avSession = AVAudioSession.sharedInstance()
                    callLogger.notice("[Call] AVAudioSession: category=\(avSession.category.rawValue, privacy: .public) mode=\(avSession.mode.rawValue, privacy: .public)")
                    rtcSession.audioSessionDidActivate(avSession)
                    rtcSession.lockForConfiguration()
                    try? rtcSession.setCategory(
                        .playAndRecord, mode: .voiceChat,
                        options: [.allowBluetooth, .defaultToSpeaker]
                    )
                    rtcSession.unlockForConfiguration()
                    rtcSession.isAudioEnabled = true
                    await self.onAudioSessionActivated()
                }
            }
        case .activeCall:
            break
        case .ringingTimeout, .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy:
            break
        }
    }

    private func handleKeyExchange(_ packet: PacketSignalPeer) {
        let peerPublicHex = packet.sharedPublic.trimmingCharacters(in: .whitespacesAndNewlines)
        guard peerPublicHex.isEmpty == false else { return }
        if sharedKey != nil,
           peerPublicHex.caseInsensitiveCompare(lastPeerSharedPublicHex) == .orderedSame {
            return
        }
        lastPeerSharedPublicHex = peerPublicHex

        ensureLocalSessionKeys()
        guard let localPrivateKey else { return }
        guard let derivedSharedKey = CallMediaCrypto.deriveSharedKey(
            localPrivateKey: localPrivateKey,
            peerPublicHex: peerPublicHex
        ) else {
            return
        }

        sharedKey = derivedSharedKey
        uiState.keyCast = derivedSharedKey.hexString
        applySenderCryptorIfPossible()
        startE2EERebindLoop()

        cancelRingTimeout()
        CallSoundManager.shared.stopAll()

        switch role {
        case .caller:
            switch signalingMode {
            case .legacy:
                ProtocolManager.shared.sendCallSignal(
                    signalType: .createRoom,
                    src: ownPublicKey,
                    dst: uiState.peerPublicKey
                )
                uiState.phase = .keyExchange
                uiState.statusText = "Finalizing call..."
                callLogger.notice("[Call] legacy path: KEY_EXCHANGE complete -> ACTIVE")
            case .createRoom:
                ProtocolManager.shared.sendCallSignal(
                    signalType: .keyExchange,
                    src: ownPublicKey,
                    dst: uiState.peerPublicKey,
                    sharedPublic: localPublicKeyHex
                )
                ProtocolManager.shared.sendCallSignal(
                    signalType: .createRoom,
                    src: ownPublicKey,
                    dst: uiState.peerPublicKey,
                    roomId: roomId
                )
                uiState.phase = .webRtcExchange
                uiState.statusText = "Creating room..."
                callLogger.notice("[Call] create-room path: KEY_EXCHANGE complete -> CREATE_ROOM")
            case .undecided:
                setSignalingMode(.createRoom, reason: "fallback on caller key exchange")
                ProtocolManager.shared.sendCallSignal(
                    signalType: .keyExchange,
                    src: ownPublicKey,
                    dst: uiState.peerPublicKey,
                    sharedPublic: localPublicKeyHex
                )
                ProtocolManager.shared.sendCallSignal(
                    signalType: .createRoom,
                    src: ownPublicKey,
                    dst: uiState.peerPublicKey,
                    roomId: roomId
                )
                uiState.phase = .webRtcExchange
                uiState.statusText = "Creating room..."
            }
        case .callee:
            switch signalingMode {
            case .legacy:
                ProtocolManager.shared.sendCallSignal(
                    signalType: .keyExchange,
                    src: ownPublicKey,
                    dst: uiState.peerPublicKey,
                    sharedPublic: localPublicKeyHex
                )
                uiState.phase = .keyExchange
                uiState.statusText = "Waiting for active signal..."
                callLogger.notice("[Call] legacy path: sent KEY_EXCHANGE, waiting for ACTIVE")
            case .createRoom, .undecided:
                uiState.phase = .keyExchange
                uiState.statusText = "Waiting for room..."
                callLogger.notice("[Call] create-room path: waiting for room signal")
            }
        case .none:
            break
        }
    }

    private func handleIceServersPacket(_ packet: PacketIceServers) {
        let mapped = packet.iceServers.compactMap { server -> RTCIceServer? in
            let url = server.url.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !url.isEmpty else { return nil }

            if url.hasPrefix("stun:") || url.hasPrefix("turn:") {
                return RTCIceServer(urlStrings: [url], username: server.username, credential: server.credential)
            }

            let transport = server.transport.trimmingCharacters(in: .whitespacesAndNewlines)
            if transport.isEmpty {
                return RTCIceServer(urlStrings: ["turn:\(url)"], username: server.username, credential: server.credential)
            }
            return RTCIceServer(urlStrings: ["turn:\(url)?transport=\(transport)"], username: server.username, credential: server.credential)
        }
        iceServers = mapped
    }

    // MARK: - Internal helpers used by delegate extension

    func setCallActiveIfNeeded() {
        guard uiState.phase != .active else { return }
        guard uiState.phase == .webRtcExchange else { return }
        // Runtime gate: avoid starting timer before media path is truly ready.
        // Unit tests may call this helper without a live peerConnection, so we
        // only enforce the gate when connection exists.
        if peerConnection != nil, (!hasConnectedTransport || !hasRemoteAudioTrack) {
            callLogger.info(
                "[Call] active gate waiting transport=\(self.hasConnectedTransport.description, privacy: .public) remoteAudio=\(self.hasRemoteAudioTrack.description, privacy: .public)"
            )
            return
        }
        uiState.phase = .active
        uiState.statusText = "Call active"
        cancelRingTimeout()
        CallSoundManager.shared.playConnected()
        CallKitManager.shared.reportOutgoingCallConnected()
        startDurationTimerIfNeeded()
        updateLiveActivity()
    }

    func startRingTimeout() {
        cancelRingTimeout()
        let isIncoming = uiState.phase == .incoming
        let timeout: Duration = isIncoming ? .seconds(45) : .seconds(60)
        ringTimeoutTask = Task { [weak self] in
            try? await Task.sleep(for: timeout)
            guard !Task.isCancelled else { return }
            guard let self else { return }
            // Verify phase hasn't changed during sleep
            if isIncoming, self.uiState.phase == .incoming {
                self.finishCall(reason: "No answer", notifyPeer: true)
            } else if !isIncoming, self.uiState.phase == .outgoing {
                self.endCall()
            }
        }
    }

    func cancelRingTimeout() {
        ringTimeoutTask?.cancel()
        ringTimeoutTask = nil
    }

    // MARK: - Live Activity

    func startLiveActivity() {
        // End any stale activities from previous calls / schema changes
        for activity in Activity<CallActivityAttributes>.activities {
            Task { await activity.end(nil, dismissalPolicy: .immediate) }
        }

        let authInfo = ActivityAuthorizationInfo()
        callLogger.info("[Call] LiveActivity: areActivitiesEnabled=\(authInfo.areActivitiesEnabled.description, privacy: .public)")
        guard authInfo.areActivitiesEnabled else {
            callLogger.info("[Call] LiveActivity DISABLED by user settings")
            return
        }
        // Compress avatar to fit ActivityKit 4KB limit while maximizing quality
        var avatarThumb: Data?
        if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) {
            avatarThumb = Self.compressAvatarForActivity(avatar)
            callLogger.info("[Call] Avatar thumb: \(avatarThumb?.count ?? 0, privacy: .public) bytes")
        } else {
            callLogger.info("[Call] No avatar for peer")
        }
        let attributes = CallActivityAttributes(
            peerName: uiState.displayName,
            peerPublicKey: uiState.peerPublicKey,
            colorIndex: RosettaColors.avatarColorIndex(for: uiState.peerTitle, publicKey: uiState.peerPublicKey),
            avatarThumb: avatarThumb
        )
        let state = CallActivityAttributes.ContentState(
            durationSec: uiState.durationSec,
            isActive: uiState.phase == .active,
            isMuted: uiState.isMuted
        )
        callLogger.info("[Call] LiveActivity starting: peerName=\(self.uiState.displayName, privacy: .public)")
        do {
            liveActivity = try Activity.request(
                attributes: attributes,
                content: .init(state: state, staleDate: nil),
                pushType: nil
            )
            callLogger.info("[Call] LiveActivity started: id=\(self.liveActivity?.id ?? "nil", privacy: .public)")
        } catch {
            callLogger.error("[Call] LiveActivity FAILED: \(error.localizedDescription, privacy: .public)")
        }
    }

    func updateLiveActivity() {
        guard let liveActivity, liveActivity.activityState == .active else { return }
        // Avatar is embedded in attributes (set at startLiveActivity)
        let state = CallActivityAttributes.ContentState(
            durationSec: uiState.durationSec,
            isActive: uiState.phase == .active,
            isMuted: uiState.isMuted
        )
        Task {
            await liveActivity.update(.init(state: state, staleDate: nil))
        }
    }

    // Avatar is embedded directly in CallActivityAttributes (32x32 thumb)

    func endLiveActivity() {
        guard let liveActivity else { return }
        let finalState = CallActivityAttributes.ContentState(
            durationSec: uiState.durationSec,
            isActive: false,
            isMuted: false
        )
        Task {
            await liveActivity.end(.init(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
        }
        self.liveActivity = nil
    }

    func attachReceiverCryptor(_ receiver: RTCRtpReceiver) {
        guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return }
        guard attachedReceiverIds.contains(receiver.receiverId) == false else { return }
        if WebRTCFrameCryptorBridge.attach(receiver, sharedKey: sharedKey) {
            attachedReceiverIds.insert(receiver.receiverId)
        }
    }

    /// Periodic E2EE rebind loop (Android parity: 1.5s interval).
    /// SFU may create new RTP senders/receivers during renegotiation.
    /// Without this, encrypted frames from new senders can't be decrypted → silence.
    func startE2EERebindLoop() {
        e2eeRebindTask?.cancel()
        e2eeRebindTask = Task { @MainActor [weak self] in
            // First iteration runs immediately (no sleep) — attaches cryptors
            // to any senders/receivers created during peer connection setup.
            // Subsequent iterations run every 1.5s (Android parity).
            var isFirstIteration = true
            while !Task.isCancelled {
                if isFirstIteration {
                    isFirstIteration = false
                } else {
                    try? await Task.sleep(for: .milliseconds(1500))
                    guard !Task.isCancelled else { return }
                }
                guard let self else { return }
                guard self.uiState.phase == .webRtcExchange || self.uiState.phase == .active else { continue }
                guard self.sharedKey != nil, let pc = self.peerConnection else { continue }
                self.applySenderCryptorIfPossible()
                for receiver in pc.receivers {
                    self.attachReceiverCryptor(receiver)
                }
            }
        }
    }

    func handleGeneratedCandidate(_ candidate: RTCIceCandidate) {
        let payload: [String: Any] = [
            "candidate": candidate.sdp,
            "sdpMid": candidate.sdpMid as Any,
            "sdpMLineIndex": Int(candidate.sdpMLineIndex),
        ]
        guard let data = try? JSONSerialization.data(withJSONObject: payload),
              let raw = String(data: data, encoding: .utf8) else {
            return
        }
        ProtocolManager.shared.sendWebRtcSignal(signalType: .iceCandidate, sdpOrCandidate: raw)
    }

    func handleIceConnectionStateChanged(_ state: RTCIceConnectionState) {
        callLogger.info("[Call] ICE state: \(state.rawValue, privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)")
        switch state {
        case .connected, .completed:
            hasConnectedTransport = true
            disconnectRecoveryTask?.cancel()
            disconnectRecoveryTask = nil
            setCallActiveIfNeeded()
        case .disconnected:
            hasConnectedTransport = false
            // Temporary — ICE may recover (WiFi↔Cellular switch, brief interruption).
            // Android waits 15s. Previous iOS code killed instantly → unstable calls.
            startDisconnectRecoveryTimer(timeout: 15)
        case .failed:
            hasConnectedTransport = false
            // More serious — unlikely to recover. Android uses 12s for PeerConnectionState.FAILED
            // (CallManager.kt:820). Previous iOS value was 5s — too aggressive, dropped calls
            // that Android would recover from on poor networks.
            startDisconnectRecoveryTimer(timeout: 12)
        case .closed:
            hasConnectedTransport = false
            finishCall(reason: "Connection closed", notifyPeer: false)
        default:
            break
        }
    }

    func startDisconnectRecoveryTimer(timeout: Int) {
        disconnectRecoveryTask?.cancel()
        disconnectRecoveryTask = Task { @MainActor [weak self] in
            callLogger.info("[Call] Waiting \(timeout, privacy: .public)s for ICE recovery...")
            try? await Task.sleep(for: .seconds(timeout))
            guard !Task.isCancelled else { return }
            guard let self else { return }
            let iceState = self.peerConnection?.iceConnectionState
            if iceState == .connected || iceState == .completed {
                callLogger.info("[Call] ICE recovered during wait — call continues")
                return
            }
            callLogger.info("[Call] ICE did not recover in \(timeout, privacy: .public)s — ending call")
            self.finishCall(reason: "Connection lost", notifyPeer: false)
        }
    }
    // MARK: - Adaptive Avatar Compressor

    /// Compresses avatar to fit ActivityKit's ~4KB attributes limit.
    /// Uses adaptive resolution + quality: starts at 80x80 high quality,
    /// progressively reduces until it fits. Avatar is ALWAYS included
    /// if the source image exists — never falls back to initials.
    static func compressAvatarForActivity(_ image: UIImage) -> Data? {
        let maxBytes = 3000 // safe margin under 4KB limit (other fields use ~500B)

        // Resolution/quality ladder — ordered from best to smallest.
        // Each step: (pixel size, JPEG qualities to try from high to low)
        let ladder: [(size: Int, qualities: [CGFloat])] = [
            (80, [0.7, 0.5, 0.3]),
            (64, [0.7, 0.5, 0.3]),
            (48, [0.6, 0.4, 0.2]),
            (36, [0.5, 0.3]),
        ]

        for step in ladder {
            let sz = CGSize(width: step.size, height: step.size)
            UIGraphicsBeginImageContextWithOptions(sz, false, 1.0)
            image.draw(in: CGRect(origin: .zero, size: sz))
            let resized = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()

            guard let resized else { continue }

            for q in step.qualities {
                if let data = resized.jpegData(compressionQuality: q), data.count <= maxBytes {
                    return data
                }
            }
        }

        // Ultimate fallback: 24x24 at minimum quality — guaranteed < 500 bytes
        let tiny = CGSize(width: 24, height: 24)
        UIGraphicsBeginImageContextWithOptions(tiny, false, 1.0)
        image.draw(in: CGRect(origin: .zero, size: tiny))
        let last = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return last?.jpegData(compressionQuality: 0.1)
    }
}

#if DEBUG
@MainActor
extension CallManager {
    /// Test-only hook to drive signal routing without a live transport.
    func testHandleSignalPacket(_ packet: PacketSignalPeer) {
        handleSignalPacket(packet)
    }

    /// Test-only helper for deterministic state setup in routing tests.
    func testSetUiState(_ state: CallUiState) {
        uiState = state
    }
}
#endif
