package com.humand.demo.modules.CallService

import android.content.Context
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import com.humand.demo.modules.Audio.AudioSessionHelper
import io.getstream.video.android.core.Call
import io.getstream.video.android.core.StreamVideo
import io.getstream.video.android.core.StreamVideoBuilder
import io.getstream.video.android.core.notifications.NotificationConfig
import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry
import io.getstream.video.android.model.User
import io.getstream.video.android.core.ParticipantState
import io.getstream.video.android.core.RingingState
import io.getstream.video.android.core.socket.common.token.TokenProvider
import stream.video.sfu.models.ParticipantSource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import org.webrtc.EglBase

/**
 * Base class for GetStream Video functionality.
 * Extended by CallManager and LivestreamManager.
 */
abstract class StreamVideoManager(protected val context: Context) {
    protected var streamVideo: StreamVideo? = null
    protected var streamCall: Call? = null
    protected var localUserId: String? = null

    protected var currentCallingState: String = CallConstants.CallingState.IDLE

    // Observation jobs
    protected var stateObservationJob: Job? = null
    protected var localParticipantObservationJob: Job? = null
    protected var reconnectionObservationJob: Job? = null
    protected var callEndObservationJob: Job? = null
    protected var activeSpeakersObservationJob: Job? = null
    protected var dominantSpeakerObservationJob: Job? = null
    protected var screenSharingObservationJob: Job? = null
    protected var pinnedParticipantsObservationJob: Job? = null
    protected var speakerDeviceObservationJob: Job? = null
    protected var ringingStateObservationJob: Job? = null
    protected var microphoneStateObservationJob: Job? = null
    protected var speakerStateObservationJob: Job? = null
    protected var cameraStateObservationJob: Job? = null

    // Per-participant media state observation jobs (to detect audio/video state changes)
    private val participantMediaObservationJobs = mutableMapOf<String, Job>()

    private var participantEmissionJob: Job? = null
    private var pendingParticipantEmission = false
    private val participantEmissionThrottleMs = 100L
    private var observedRemoteParticipantSessionIds = emptySet<String>()

    // Flag to prevent observation from interfering during initial media configuration
    @Volatile
    protected var isConfiguringMedia: Boolean = false

    /**
     * Whether setSpeakerEnabled should use MODE_IN_COMMUNICATION and setCommunicationDevice.
     * True for calls and host livestreams (which send/receive audio via WebRTC).
     * False for viewer livestreams (receive-only) where MODE_NORMAL with STREAM_MUSIC
     * is needed so Android allows volume to reach 0.
     */
    protected open val usesCommunicationAudioMode: Boolean = true

    protected val managerScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    private val audioManager: AudioManager by lazy {
        context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
    }

    /**
     * True when connection reached JOINED state.
     */
    protected fun hasJoinedCallState(): Boolean {
        return currentCallingState == CallConstants.CallingState.JOINED
    }

    /**
     * Execute block only if there is an active Stream call.
     */
    protected inline fun <T> withActiveCall(action: (Call) -> T): T? {
        val call = streamCall ?: return null
        return action(call)
    }

    /**
     * Whether Stream SDK already reports the call as ended.
     */
    protected fun hasCallEnded(): Boolean {
        return streamCall?.state?.endedAt?.value != null
    }

    /**
     * Abstract method for subclasses to implement logging.
     */
    protected abstract fun logMessage(message: String)

    /**
     * Abstract method for subclasses to emit state changes.
     */
    protected abstract fun emitStateChanged(state: String)

    /**
     * Abstract method for subclasses to emit participant changes.
     */
    protected abstract fun emitParticipantsChanged(participants: List<NativeParticipant>)

    /**
     * Abstract method for subclasses to emit media state changes.
     */
    protected abstract fun emitMediaStateChanged(microphoneEnabled: Boolean, cameraEnabled: Boolean, speakerEnabled: Boolean)

    /**
     * Abstract method called when call ends remotely.
     */
    protected abstract fun onCallEndedByRemote(endReason: String)

    /**
     * Hook invoked whenever the Stream microphone state changes.
     */
    protected open fun onMicrophoneStateChanged(isEnabled: Boolean) {}

    /**
     * Hook invoked whenever the Stream speaker state changes.
     */
    protected open fun onSpeakerStateChanged(isEnabled: Boolean) {}

    /**
     * Hook invoked whenever the Stream camera state changes.
     */
    protected open fun onCameraStateChanged(isEnabled: Boolean) {}

    /**
     * Build the CallServiceConfigRegistry for the StreamVideoBuilder.
     * Subclasses can override to customize foreground service behavior per call type.
     */
    protected open fun buildCallServiceConfigRegistry(): CallServiceConfigRegistry {
        return CallServiceConfigRegistry()
    }

    /**
     * Hook for subclasses to stop ringback tone when remote participants join.
     */
    protected open fun stopRingback() {}

    /**
     * Hook for subclasses to react to remote participant count changes.
     */
    protected open fun onRemoteParticipantsChanged(remoteCount: Int) {}

    /**
     * Hook for subclasses to react to newly joined remote participants.
     */
    protected open fun onRemoteParticipantsJoined(joinedParticipants: List<ParticipantState>) {}

    /**
     * Ensure StreamVideo client exists and return it.
     */
    protected suspend fun ensureStreamClient(
        apiKey: String,
        userId: String,
        token: String,
        tokenRefreshHandler: suspend () -> String?
    ): StreamVideo {
        streamVideo?.let {
            // Verify our local reference is still the active SDK instance
            // (another manager may have called StreamVideo.removeClient())
            try {
                val sdkInstance = StreamVideo.instance()
                if (sdkInstance === it) {
                    localUserId = userId
                    return it
                }
                logMessage("ensureStreamClient: Local client is stale, will create new one")
                streamVideo = null
            } catch (_: IllegalStateException) {
                logMessage("ensureStreamClient: No SDK instance, local reference is stale")
                streamVideo = null
            }
        }

        // The Stream SDK enforces a single global instance.
        // Remove any stale instance from another manager before creating a new one.
        try {
            val existing = StreamVideo.instance()
            logMessage("Removing existing StreamVideo instance before creating new one")
            StreamVideo.removeClient()
        } catch (_: IllegalStateException) {
            // No existing instance — proceed to create one
        }

        logMessage("Initializing StreamVideo client for user: $userId")
        localUserId = userId

        val userResponse = CallApiService.getCurrentUser()
        val userName = userResponse?.displayName

        logMessage("Creating Stream user with id: $userId, name: $userName")

        val user = User(id = userId, name = userName)

        val customNotificationHandler = CustomNotificationHandler(
            context.applicationContext as android.app.Application
        )
        customNotificationHandler.resetCallStartTime()

        val notificationConfig = NotificationConfig(
            pushDeviceGenerators = emptyList(),
            autoRegisterPushDevice = false,
            hideRingingNotificationInForeground = true,
            requestPermissionOnAppLaunch = { false },
            notificationHandler = customNotificationHandler
        )

        val provider = object : TokenProvider {
            override suspend fun loadToken(): String {
                return tokenRefreshHandler()
                    ?: throw IllegalStateException("Failed to refresh call token")
            }
        }

        val client = StreamVideoBuilder(
            context = context,
            apiKey = apiKey,
            user = user,
            token = token,
            tokenProvider = provider,
            notificationConfig = notificationConfig,
            callServiceConfigRegistry = buildCallServiceConfigRegistry(),
            permissionCheck = HumandCallPermissionCheck(context.applicationContext)
        ).build()

        streamVideo = client
        logMessage("StreamVideo client initialized")
        return client
    }

    /**
     * Start observing call state changes.
     */
    /**
     * Stable sort: pinned, screen-share, WebRTC over RTMP, join time, user ID.
     * Camera-configured streams must surface even when an external RTMP source is publishing.
     */
    private fun createParticipantComparator(pinnedSessionIds: Set<String>) =
        Comparator<ParticipantState> { a, b ->
        val pinnedA = if (pinnedSessionIds.contains(a.sessionId)) 0 else 1
        val pinnedB = if (pinnedSessionIds.contains(b.sessionId)) 0 else 1
        if (pinnedA != pinnedB) return@Comparator pinnedA.compareTo(pinnedB)

        val ssA = if (a.screenSharingEnabled.value) 0 else 1
        val ssB = if (b.screenSharingEnabled.value) 0 else 1
        if (ssA != ssB) return@Comparator ssA.compareTo(ssB)

        // Stream uses WEBRTC_UNSPECIFIED for standard WebRTC publishers.
        val webrtcA = if (a.source == ParticipantSource.PARTICIPANT_SOURCE_WEBRTC_UNSPECIFIED) 0 else 1
        val webrtcB = if (b.source == ParticipantSource.PARTICIPANT_SOURCE_WEBRTC_UNSPECIFIED) 0 else 1
        if (webrtcA != webrtcB) return@Comparator webrtcA.compareTo(webrtcB)

        // Then by join time (earlier first)
        val joinA = a.joinedAt.value
        val joinB = b.joinedAt.value
        if (joinA != null && joinB != null) {
            val joinCmp = joinA.compareTo(joinB)
            if (joinCmp != 0) return@Comparator joinCmp
        } else if (joinA != null) {
            return@Comparator -1
        } else if (joinB != null) {
            return@Comparator 1
        }

        // Finally by user ID for deterministic order
        a.userId.value.compareTo(b.userId.value)
    }

    protected fun startStateObservation(observeRingingState: Boolean = true, observeCallEnd: Boolean = true) {
        val call = streamCall ?: return

        emitCurrentMediaState()

        // Apply stable sorting to the SDK's internal participant state
        val pinnedSessionIds = call.state.pinnedParticipants.value.keys.toSet()
        call.state.updateParticipantSortingOrder(
            createParticipantComparator(pinnedSessionIds)
        )

        // Observe participant list changes (join/leave)
        stateObservationJob = managerScope.launch {
            call.state.participants.collectLatest { participants ->
                logMessage("Participants changed: ${participants.size}")
                val remoteParticipants = participants.filter { it.userId.value != localUserId }
                val currentRemoteSessionIds = remoteParticipants.map { it.sessionId }.toSet()
                val joinedRemoteParticipants = remoteParticipants.filter {
                    it.sessionId !in observedRemoteParticipantSessionIds
                }
                observedRemoteParticipantSessionIds = currentRemoteSessionIds
                if (remoteParticipants.isNotEmpty()) {
                    stopRingback()
                    ringingStateObservationJob?.cancel()
                    ringingStateObservationJob = null
                }
                onRemoteParticipantsChanged(remoteParticipants.size)
                if (joinedRemoteParticipants.isNotEmpty()) {
                    onRemoteParticipantsJoined(joinedRemoteParticipants)
                }
                updateParticipantMediaObservers(participants)
                emitCurrentParticipants()
            }
        }

        pinnedParticipantsObservationJob = managerScope.launch {
            call.state.pinnedParticipants.collectLatest { pinnedParticipants ->
                val snapshot = pinnedParticipants.keys.toSet()
                call.state.updateParticipantSortingOrder(
                    createParticipantComparator(snapshot)
                )
                emitCurrentParticipants()
            }
        }

        // Observe local participant becoming available
        localParticipantObservationJob = managerScope.launch {
            call.state.localParticipant.collectLatest { localParticipant ->
                if (localParticipant != null) {
                    logMessage("Local participant available")
                    // Set up media observer for local participant if not already observing
                    val sessionId = localParticipant.sessionId
                    if (!participantMediaObservationJobs.containsKey(sessionId)) {
                        participantMediaObservationJobs[sessionId] = managerScope.launch {
                            combine(localParticipant.audioEnabled, localParticipant.videoEnabled) { audioEnabled, videoEnabled ->
                                Pair(audioEnabled, videoEnabled)
                            }.collectLatest { (audioEnabled, videoEnabled) ->
                                logMessage("Local participant media state changed: audio=$audioEnabled, video=$videoEnabled")
                                emitCurrentParticipants()
                            }
                        }
                        logMessage("Started media observer for local participant: $sessionId")
                    }
                    emitCurrentParticipants()
                }
            }
        }

        // Observe active speakers changes (speaking state)
        activeSpeakersObservationJob = managerScope.launch {
            call.state.activeSpeakers.collectLatest { activeSpeakers ->
                logMessage("Active speakers changed: ${activeSpeakers.size}")
                emitCurrentParticipants()
            }
        }

        // Observe dominant speaker changes
        dominantSpeakerObservationJob = managerScope.launch {
            call.state.dominantSpeaker.collectLatest { dominantSpeaker ->
                logMessage("Dominant speaker changed: ${dominantSpeaker?.sessionId}")
                emitCurrentParticipants()
            }
        }

        // Observe screen sharing session changes
        screenSharingObservationJob = managerScope.launch {
            call.state.screenSharingSession.collectLatest { screenSharingSession ->
                logMessage("Screen sharing session changed: ${screenSharingSession?.participant?.sessionId}")
                emitCurrentParticipants()
            }
        }

        // Observe reconnection status
        reconnectionObservationJob = managerScope.launch {
            call.state.connection.collectLatest { connection ->
                handleConnectionStatus(connection)
            }
        }

        // Observe call end - only for 1:1 calls
        // In group calls, endedAt may be set when another participant ends the call,
        // but remaining participants should stay connected
        if (observeCallEnd) {
            callEndObservationJob = managerScope.launch {
                call.state.endedAt.collectLatest { endedAt ->
                    if (endedAt != null) {
                        logMessage("Call ended remotely at: $endedAt")
                        onCallEndedByRemote(CallConstants.ObserverEndReason.REMOTE_HANGUP)
                    }
                }
            }
        }

        // Observe ringing state (detects rejection by callee) - only for 1:1 calls
        if (observeRingingState) {
            ringingStateObservationJob = managerScope.launch {
                call.state.ringingState.collectLatest { ringingState ->
                    logMessage("Ringing state changed: $ringingState")
                    when (ringingState) {
                        is RingingState.RejectedByAll -> {
                            logMessage("Call rejected by all callees")
                            onCallEndedByRemote(CallConstants.ObserverEndReason.REJECTED)
                        }
                        is RingingState.TimeoutNoAnswer -> {
                            logMessage("Call timed out with no answer")
                            onCallEndedByRemote(CallConstants.ObserverEndReason.TIMEOUT)
                        }
                        else -> { /* no-op */ }
                    }
                }
            }
        }

        // Observe speaker device changes
        logMessage("Starting speaker device observation")
        speakerDeviceObservationJob = managerScope.launch {
            logMessage("Speaker device observation job started")
            call.speaker.selectedDevice.collectLatest { device ->
                val audioRoute = when {
                    device?.name?.contains("Speaker", ignoreCase = true) == true -> "SPEAKER"
                    device?.name?.contains("Earpiece", ignoreCase = true) == true -> "EARPIECE"
                    device?.name?.contains("Bluetooth", ignoreCase = true) == true -> "BLUETOOTH"
                    device?.name?.contains("Wired", ignoreCase = true) == true -> "WIRED_HEADPHONES"
                    else -> "EARPIECE"
                }
                logMessage("Speaker device changed: $device -> audioRoute=$audioRoute")
                emitAudioRouteChanged(audioRoute)
            }
        }

        cameraStateObservationJob = managerScope.launch {
            call.camera.isEnabled.collectLatest { enabled ->
                if (isConfiguringMedia) return@collectLatest
                logMessage("Camera state observed: $enabled")
                emitMediaStateChanged(call.microphone.isEnabled.value, enabled, call.speaker.isEnabled.value)
                onCameraStateChanged(enabled)
            }
        }

        microphoneStateObservationJob = managerScope.launch {
            call.microphone.isEnabled.collectLatest { enabled ->
                if (isConfiguringMedia) return@collectLatest
                logMessage("Microphone state observed: $enabled")
                emitMediaStateChanged(enabled, call.camera.isEnabled.value, call.speaker.isEnabled.value)
                onMicrophoneStateChanged(enabled)
            }
        }

        speakerStateObservationJob = managerScope.launch {
            call.speaker.isEnabled.collectLatest { enabled ->
                if (isConfiguringMedia) return@collectLatest
                logMessage("Speaker state observed: $enabled")
                emitMediaStateChanged(call.microphone.isEnabled.value, call.camera.isEnabled.value, enabled)
                onSpeakerStateChanged(enabled)
            }
        }
    }

    /**
     * Emit audio route change to React Native (mirrors iOS behavior).
     */
    protected open fun emitAudioRouteChanged(audioRoute: String) {
    }

    /**
     * Stop all state observation jobs.
     */
    protected fun stopStateObservation() {
        logMessage("Stopping state observation")
        stateObservationJob?.cancel()
        localParticipantObservationJob?.cancel()
        activeSpeakersObservationJob?.cancel()
        dominantSpeakerObservationJob?.cancel()
        screenSharingObservationJob?.cancel()
        pinnedParticipantsObservationJob?.cancel()
        reconnectionObservationJob?.cancel()
        callEndObservationJob?.cancel()
        ringingStateObservationJob?.cancel()
        ringingStateObservationJob = null
        speakerDeviceObservationJob?.cancel()
        pinnedParticipantsObservationJob = null

        // Cancel all per-participant media observers
        participantMediaObservationJobs.values.forEach { it.cancel() }
        participantMediaObservationJobs.clear()

        cameraStateObservationJob?.cancel()
        cameraStateObservationJob = null
        microphoneStateObservationJob?.cancel()
        microphoneStateObservationJob = null
        speakerStateObservationJob?.cancel()
        speakerStateObservationJob = null

        participantEmissionJob?.cancel()
        participantEmissionJob = null
        pendingParticipantEmission = false
        observedRemoteParticipantSessionIds = emptySet()

        isConfiguringMedia = false
    }

    /**
     * Update per-participant media state observers.
     * This ensures we observe audioEnabled/videoEnabled changes for each participant,
     * which don't trigger the participants list to re-emit (unlike iOS).
     */
    private fun updateParticipantMediaObservers(participants: List<ParticipantState>) {
        val currentSessionIds = participants.map { it.sessionId }.toSet()
        val observedSessionIds = participantMediaObservationJobs.keys.toSet()

        // Cancel observers for participants that have left
        val removedSessionIds = observedSessionIds - currentSessionIds
        removedSessionIds.forEach { sessionId ->
            participantMediaObservationJobs[sessionId]?.cancel()
            participantMediaObservationJobs.remove(sessionId)
            logMessage("Stopped media observer for participant: $sessionId")
        }

        // Create observers for new participants using composite audio/video flows
        val nextSessionIds = currentSessionIds - observedSessionIds
        participants.filter { it.sessionId in nextSessionIds }.forEach { participant ->
            val sessionId = participant.sessionId
            participantMediaObservationJobs[sessionId] = managerScope.launch {
                // Combine audioEnabled and videoEnabled into a single observer
                combine(participant.audioEnabled, participant.videoEnabled) { audioEnabled, videoEnabled ->
                    Pair(audioEnabled, videoEnabled)
                }.collectLatest { (audioEnabled, videoEnabled) ->
                    logMessage("Participant $sessionId media state changed: audio=$audioEnabled, video=$videoEnabled")
                    emitCurrentParticipants()
                }
            }
            logMessage("Started media observer for participant: $sessionId")
        }
    }

    /**
     * Handle connection status changes.
     */
    private fun handleConnectionStatus(connection: io.getstream.video.android.core.RealtimeConnection) {
        val nextState = when (connection) {
            is io.getstream.video.android.core.RealtimeConnection.Connected -> CallConstants.CallingState.JOINED
            is io.getstream.video.android.core.RealtimeConnection.Reconnecting -> CallConstants.CallingState.RECONNECTING
            is io.getstream.video.android.core.RealtimeConnection.Failed -> CallConstants.CallingState.RECONNECTING_FAILED
            else -> currentCallingState
        }

        if (nextState != currentCallingState) {
            logMessage("Connection status changed: $currentCallingState -> $nextState")
            currentCallingState = nextState
            emitStateChanged(nextState)

            // If connection failed permanently, trigger cleanup to stop reconnection attempts
            // This handles "call not found" errors where the call no longer exists on the server
            if (connection is io.getstream.video.android.core.RealtimeConnection.Failed) {
                logMessage("Connection failed permanently, triggering cleanup")
                onConnectionFailed()
            }
        }
    }

    /**
     * Called when connection fails permanently. Override in subclass to handle cleanup.
     */
    protected open fun onConnectionFailed() {
        // Default implementation does nothing - subclasses should override
    }

    /**
     * Emit current participants list with throttling.
     */
    protected fun emitCurrentParticipants() {
        if (participantEmissionJob?.isActive == true) {
            pendingParticipantEmission = true
            return
        }

        participantEmissionJob = managerScope.launch {
            delay(participantEmissionThrottleMs)
            emitCurrentParticipantsImmediate()

            while (pendingParticipantEmission) {
                pendingParticipantEmission = false
                delay(participantEmissionThrottleMs)
                emitCurrentParticipantsImmediate()
            }
        }
    }

    /**
     * Immediately emit current participants list without throttling.
     * Participants are sorted stably by pin/screen-share/join-time/user-id
     * so the grid does not reshuffle when speaking or video state changes.
     */
    private fun emitCurrentParticipantsImmediate() {
        val call = streamCall ?: return
        val pinnedSessionIds = call.state.pinnedParticipants.value.keys.toSet()
        val participants = call.state.participants.value.sortedWith(
            createParticipantComparator(pinnedSessionIds)
        )

        val nativeParticipants = participants.map { participant ->
            participantToNative(participant)
        }.toMutableList()

        // Include local participant
        val localParticipant = call.state.localParticipant.value
        if (localParticipant != null) {
            val localAlreadyIncluded = participants.any { it.userId.value == localParticipant.userId.value }
            if (!localAlreadyIncluded) {
                nativeParticipants.add(0, participantToNative(localParticipant))
            }
        }

        emitParticipantsChanged(nativeParticipants)
    }

    /**
     * Convert GetStream participant to native format.
     */
    protected fun participantToNative(participant: ParticipantState): NativeParticipant {
        val qualityString = try {
            val quality = participant.networkQuality.value.toString()
            when {
                quality.contains("Excellent", ignoreCase = true) -> CallConstants.ConnectionQuality.EXCELLENT
                quality.contains("Good", ignoreCase = true) -> CallConstants.ConnectionQuality.GOOD
                quality.contains("Poor", ignoreCase = true) -> CallConstants.ConnectionQuality.POOR
                else -> CallConstants.ConnectionQuality.UNKNOWN
            }
        } catch (e: Exception) {
            CallConstants.ConnectionQuality.UNKNOWN
        }

        val isLocal = participant.userId.value == localUserId

        // For local participant, use camera.isEnabled directly as source of truth
        // to avoid race condition where participant.videoEnabled hasn't updated yet
        // after setCameraEnabled() is called
        val hasVideo = if (isLocal) {
            streamCall?.camera?.isEnabled?.value ?: participant.videoEnabled.value
        } else {
            participant.videoEnabled.value
        }

        val sourceString = when (participant.source) {
            ParticipantSource.PARTICIPANT_SOURCE_RTMP -> "rtmp"
            ParticipantSource.PARTICIPANT_SOURCE_WHIP -> "whip"
            ParticipantSource.PARTICIPANT_SOURCE_SIP -> "sip"
            ParticipantSource.PARTICIPANT_SOURCE_RTSP -> "rtsp"
            ParticipantSource.PARTICIPANT_SOURCE_SRT -> "srt"
            else -> "webrtc"
        }

        return NativeParticipant(
            sessionId = participant.sessionId,
            oduserId = participant.userId.value,
            name = participant.userNameOrId.value,
            profileImageURL = participant.image.value,
            isSpeaking = participant.speaking.value,
            isDominantSpeaker = participant.dominantSpeaker.value,
            hasVideo = hasVideo,
            hasAudio = participant.audioEnabled.value,
            isScreenSharing = participant.screenSharingEnabled.value,
            isLocalParticipant = isLocal,
            connectionQuality = qualityString,
            source = sourceString
        )
    }

    /**
     * Emit current media state.
     */
    protected fun emitCurrentMediaState() {
        val call = streamCall ?: return
        val micEnabled = call.microphone.isEnabled.value
        val camEnabled = call.camera.isEnabled.value
        val speakerEnabled = call.speaker.isEnabled.value
        emitMediaStateChanged(micEnabled, camEnabled, speakerEnabled)
    }

    /**
     * Enable camera.
     * @param emitState Whether to emit state change event (set to false during batch operations)
     */
    suspend fun setCameraEnabled(enabled: Boolean, emitState: Boolean = true) {
        val call = streamCall ?: return
        try {
            call.camera.setEnabled(enabled)
            logMessage("Camera ${if (enabled) "enabled" else "disabled"}")

            // Auto-enable speaker when video is turned on, unless BT/headset is connected
            if (enabled && !isExternalAudioDeviceConnected()) {
                setSpeakerEnabled(true, emitState = false)
                logMessage("setCameraEnabled: auto-enabled speaker (no external audio)")
            }

            if (emitState) {
                emitCurrentMediaState()
                emitCurrentParticipants()
            }
        } catch (e: Exception) {
            logMessage("setCameraEnabled error: ${e.message}")
        }
    }

    private fun isExternalAudioDeviceConnected(): Boolean {
        val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
        for (device in devices) {
            when (device.type) {
                AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
                AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
                AudioDeviceInfo.TYPE_WIRED_HEADSET,
                AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
                AudioDeviceInfo.TYPE_USB_HEADSET -> {
                    logMessage("isExternalAudioDeviceConnected: Found device type ${device.type}")
                    return true
                }
            }
        }
        return false
    }

    /**
     * Enable microphone.
     * @param emitState Whether to emit state change event (set to false during batch operations)
     */
    suspend fun setMicrophoneEnabled(enabled: Boolean, emitState: Boolean = true) {
        val call = streamCall ?: return
        try {
            call.microphone.setEnabled(enabled)
            logMessage("Microphone ${if (enabled) "enabled" else "disabled"}")
            if (emitState) {
                emitCurrentMediaState()
            }
        } catch (e: Exception) {
            logMessage("setMicrophoneEnabled error: ${e.message}")
        }
    }

    /**
     * Enable speaker.
     * @param emitState Whether to emit state change event (set to false during batch operations)
     */
    suspend fun setSpeakerEnabled(enabled: Boolean, emitState: Boolean = true) {
        val call = streamCall ?: return
        try {
            if (usesCommunicationAudioMode) {
                audioManager.mode = AudioManager.MODE_IN_COMMUNICATION

                // Set communication device for Android 12+ (API 31+)
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                    // Clear previous device first to release any active BT SCO link
                    audioManager.clearCommunicationDevice()
                    val devices = audioManager.availableCommunicationDevices
                    if (enabled) {
                        val speakerDevice =
                            devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
                        if (speakerDevice != null) {
                            audioManager.setCommunicationDevice(speakerDevice)
                            logMessage("setSpeakerEnabled: set communication device to ${speakerDevice.productName}")
                        } else {
                            logMessage("setSpeakerEnabled: no speaker communication device available, forcing speakerphone")
                            @Suppress("DEPRECATION")
                            audioManager.isSpeakerphoneOn = true
                        }
                        emitAudioRouteChanged("SPEAKER")
                    } else {
                        // Prefer Bluetooth/wired headset over earpiece when disabling speaker
                        val externalDevice = devices.firstOrNull {
                            it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
                            it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
                            it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
                            it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
                            it.type == AudioDeviceInfo.TYPE_USB_HEADSET
                        }
                        val targetDevice = externalDevice ?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }
                        targetDevice?.let { device ->
                            audioManager.setCommunicationDevice(device)
                            logMessage("setSpeakerEnabled: set communication device to ${device.productName}")
                            emitAudioRouteChanged(routeNameForType(device.type))
                        } ?: run {
                            emitAudioRouteChanged("EARPIECE")
                        }
                    }
                } else {
                    @Suppress("DEPRECATION")
                    audioManager.isSpeakerphoneOn = enabled
                    if (enabled) {
                        emitAudioRouteChanged("SPEAKER")
                    } else if (audioManager.isBluetoothScoOn) {
                        emitAudioRouteChanged("BLUETOOTH")
                    } else if (audioManager.isWiredHeadsetOn) {
                        emitAudioRouteChanged("WIRED_HEADPHONES")
                    } else {
                        emitAudioRouteChanged("EARPIECE")
                    }
                }
            } else {
                // Non-communication mode (e.g. livestream viewer): use simple speakerphone toggle.
                // Re-apply MODE_NORMAL in case WebRTC's PeerConnection setup changed it to
                // MODE_IN_COMMUNICATION during call.join(). MODE_NORMAL uses STREAM_MUSIC
                // which allows volume to reach 0.
                audioManager.mode = AudioManager.MODE_NORMAL
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                    audioManager.clearCommunicationDevice()
                }

                // When casting to an external display (Chromecast/HDMI/etc.), do NOT force
                // speakerphone as that overrides Android's media routing to the external device.
                val hasExternalDisplay = AudioSessionHelper.getInstance(context).isExternalDisplayConnected()
                if (hasExternalDisplay) {
                    @Suppress("DEPRECATION")
                    audioManager.isSpeakerphoneOn = false
                    logMessage("setSpeakerEnabled: non-communication mode, external display detected, not forcing speaker")
                } else {
                    @Suppress("DEPRECATION")
                    audioManager.isSpeakerphoneOn = enabled
                    logMessage("setSpeakerEnabled: non-communication mode, speakerphone=${enabled}")
                }
                emitAudioRouteChanged(if (enabled && !hasExternalDisplay) "SPEAKER" else "EARPIECE")
            }

            call.speaker.setSpeakerPhone(enabled)
            logMessage("Speakerphone ${if (enabled) "enabled" else "disabled"}")
            if (emitState) {
                emitMediaStateChanged(call.microphone.isEnabled.value, call.camera.isEnabled.value, enabled)
            }
        } catch (e: Exception) {
            logMessage("setSpeakerEnabled error: ${e.message}")
        }
    }

    private fun routeNameForType(type: Int): String {
        return when (type) {
            AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "SPEAKER"
            AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
            AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BLUETOOTH"
            AudioDeviceInfo.TYPE_WIRED_HEADSET,
            AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
            AudioDeviceInfo.TYPE_USB_HEADSET -> "WIRED_HEADPHONES"
            else -> "EARPIECE"
        }
    }

    /**
     * Flip/switch camera.
     */
    suspend fun switchCamera() {
        val call = streamCall ?: return
        try {
            call.camera.flip()
            logMessage("Camera flipped")
        } catch (e: Exception) {
            logMessage("switchCamera error: ${e.message}")
        }
    }

    /**
     * Get participant by session ID.
     * Used by CallVideoView for rendering.
     */
    fun getParticipant(sessionId: String): ParticipantState? {
        return withActiveCall { call ->
            val localParticipant = call.state.localParticipant.value
            if (localParticipant?.sessionId == sessionId) {
                return@withActiveCall localParticipant
            }

            call.state.participants.value.find { it.sessionId == sessionId }
        }
    }

    /**
     * Get local participant.
     */
    fun getLocalParticipant(): ParticipantState? {
        return withActiveCall { call -> call.state.localParticipant.value }
    }

    /**
     * Get the EGL base context for video rendering.
     * This returns the shared context from GetStream's camera manager.
     */
    fun getEglBaseContext(): EglBase.Context? {
        return withActiveCall { call -> call.camera.eglBaseContext }
    }

    /**
     * Get the Call object for track visibility management.
     */
    fun getCall(): Call? {
        return streamCall
    }

    /**
     * Get current call state.
     */
    fun getCurrentState(): NativeCallState {
        val call = streamCall
        if (call == null) {
            return NativeCallState(
                callingState = CallConstants.CallingState.IDLE,
                participants = emptyList(),
                localParticipant = null,
                microphoneEnabled = false,
                cameraEnabled = false,
                speakerEnabled = false
            )
        }

        val participants = call.state.participants.value.map { participantToNative(it) }
        val localParticipant = call.state.localParticipant.value?.let { participantToNative(it) }
        val micEnabled = call.microphone.isEnabled.value
        val speakerEnabled = call.speaker.isEnabled.value

        return NativeCallState(
            callingState = currentCallingState,
            participants = participants,
            localParticipant = localParticipant,
            microphoneEnabled = micEnabled,
            cameraEnabled = call.camera.isEnabled.value,
            speakerEnabled = speakerEnabled
        )
    }

    /**
     * Cleanup stream client.
     */
    protected suspend fun cleanupStreamClient() {
        logMessage("Cleaning up stream client")

        stopStateObservation()

        val callToCleanup = streamCall
        this.streamCall = null

        // Disable media first, before leaving - do this even if call is in bad state
        callToCleanup?.let { call ->
            try {
                call.camera.setEnabled(false)
            } catch (e: Exception) {
                logMessage("Error disabling camera: ${e.message}")
            }
            try {
                call.microphone.setEnabled(false)
            } catch (e: Exception) {
                logMessage("Error disabling microphone: ${e.message}")
            }
        }

        // Now try to leave the call
        callToCleanup?.let { call ->
            try {
                call.leave()
                logMessage("Left call successfully")
            } catch (e: Exception) {
                logMessage("Error leaving call: ${e.message}")
                // Even if leave fails, we still need to cleanup the client
            }
        }

        currentCallingState = CallConstants.CallingState.IDLE
    }

    /**
     * Disconnect and clear the StreamVideo client.
     * This ensures no stale state (cached calls, WebRTC sessions, stats reporters)
     * persists between calls.
     */
    protected suspend fun disconnectStreamVideo() {
        val client = streamVideo ?: run {
            logMessage("disconnectStreamVideo: No client to disconnect")
            return
        }

        logMessage("disconnectStreamVideo: Disconnecting StreamVideo client")
        try {
            // Call logOut to properly cleanup the StreamVideo client
            // This disconnects WebSocket, stops foreground service, and clears state
            StreamVideo.removeClient()
        } catch (e: Exception) {
            logMessage("disconnectStreamVideo: Error during removeClient: ${e.message}")
        }
        streamVideo = null
        logMessage("disconnectStreamVideo: StreamVideo client disconnected and cleared")
    }

    /**
     * Retry helper with fixed delay.
     */
    protected suspend fun <T> withRetry(
        maxAttempts: Int = 3,
        delayMs: Long = 2000L,
        operation: suspend () -> T
    ): T {
        var lastError: Exception? = null

        for (attempt in 1..maxAttempts) {
            try {
                return operation()
            } catch (e: Exception) {
                lastError = e
                logMessage("Retry attempt $attempt/$maxAttempts failed: ${e.message}")

                if (attempt < maxAttempts) {
                    delay(delayMs)
                }
            }
        }

        throw lastError ?: Exception("All retry attempts failed")
    }
}
