/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.audio

import com.amazonaws.services.chime.sdk.meetings.analytics.EventAnalyticsController
import com.amazonaws.services.chime.sdk.meetings.analytics.EventAttributeName
import com.amazonaws.services.chime.sdk.meetings.analytics.EventName
import com.amazonaws.services.chime.sdk.meetings.analytics.MeetingHistoryEventName
import com.amazonaws.services.chime.sdk.meetings.analytics.MeetingStatsCollector
import com.amazonaws.services.chime.sdk.meetings.audiovideo.AttendeeInfo
import com.amazonaws.services.chime.sdk.meetings.audiovideo.AudioVideoObserver
import com.amazonaws.services.chime.sdk.meetings.audiovideo.PrimaryMeetingPromotionObserver
import com.amazonaws.services.chime.sdk.meetings.audiovideo.SignalStrength
import com.amazonaws.services.chime.sdk.meetings.audiovideo.SignalUpdate
import com.amazonaws.services.chime.sdk.meetings.audiovideo.Transcript
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptAlternative
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptEntity
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptEvent
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptItem
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptItemType
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptLanguageWithScore
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptResult
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptionStatus
import com.amazonaws.services.chime.sdk.meetings.audiovideo.TranscriptionStatusType
import com.amazonaws.services.chime.sdk.meetings.audiovideo.VolumeLevel
import com.amazonaws.services.chime.sdk.meetings.audiovideo.VolumeUpdate
import com.amazonaws.services.chime.sdk.meetings.internal.AttendeeStatus
import com.amazonaws.services.chime.sdk.meetings.internal.SessionStateControllerAction
import com.amazonaws.services.chime.sdk.meetings.internal.metric.ClientMetricsCollector
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ConcurrentSet
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ObserverUtils
import com.amazonaws.services.chime.sdk.meetings.realtime.RealtimeObserver
import com.amazonaws.services.chime.sdk.meetings.realtime.TranscriptEventObserver
import com.amazonaws.services.chime.sdk.meetings.session.MeetingSessionConfiguration
import com.amazonaws.services.chime.sdk.meetings.session.MeetingSessionStatus
import com.amazonaws.services.chime.sdk.meetings.session.MeetingSessionStatusCode
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger
import com.xodee.client.audio.audioclient.AttendeeUpdate
import com.xodee.client.audio.audioclient.AudioClient
import com.xodee.client.audio.audioclient.transcript.Transcript as TranscriptInternal
import com.xodee.client.audio.audioclient.transcript.TranscriptEvent as TranscriptEventInternal
import com.xodee.client.audio.audioclient.transcript.TranscriptionStatus as TranscriptionStatusInternal
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch

class DefaultAudioClientObserver(
    private val logger: Logger,
    private val clientMetricsCollector: ClientMetricsCollector,
    private val configuration: MeetingSessionConfiguration,
    private val meetingStatsCollector: MeetingStatsCollector,
    private val eventAnalyticsController: EventAnalyticsController,
    var audioClient: AudioClient? = null
) : AudioClientObserver {
    private val TAG = "DefaultAudioClientObserver"

    /**
     * currentAudioState is the current state of the [AudioClient]. For example, it is
     * connecting, or has connected, or is disconnecting. [AudioClient] will return different
     * status codes but we will map it to [SessionStateControllerAction].
     *
     * currentAudioStatus is the reason for the state change. For example, status has changed to
     * disconnected because of an internal server error. [AudioClient] also will return different
     * codes that we map to [MeetingSessionStatusCode].
     */
    private var currentAudioState = SessionStateControllerAction.Init
    private var currentAudioStatus: MeetingSessionStatusCode? = MeetingSessionStatusCode.OK

    private var audioClientStateObservers = ConcurrentSet.createConcurrentSet<AudioVideoObserver>()
    private var realtimeEventObservers = ConcurrentSet.createConcurrentSet<RealtimeObserver>()
    private var transcriptEventObservers = ConcurrentSet.createConcurrentSet<TranscriptEventObserver>()

    /**
     * Volume state change can be used to figure out the meeting's current attendees.
     * Keep track of attendees here to determine additions / removals to notify observers
     */
    private var currentAttendees = mutableSetOf<AttendeeInfo>()
    private var currentAttendeeVolumeMap = mapOf<String, VolumeUpdate>()
    private var currentAttendeeSignalMap = mapOf<String, SignalUpdate>()

    override var primaryMeetingPromotionObserver: PrimaryMeetingPromotionObserver? = null

    override fun onAudioClientStateChange(newInternalAudioState: Int, newInternalAudioStatus: Int) {
        val newAudioState: SessionStateControllerAction = toAudioClientState(newInternalAudioState)
        val newAudioStatus: MeetingSessionStatusCode? = toAudioStatus(newInternalAudioStatus)

        if (newAudioStatus == null) {
            logger.warn(
                TAG,
                "AudioClient State raw value: $newInternalAudioState Unknown Status raw value: $newInternalAudioStatus"
            )
        } else {
            logger.info(TAG, "AudioClient State: $newAudioState Status: $newAudioStatus")
        }

        if (newAudioState == SessionStateControllerAction.Unknown) return
        if (newAudioState == currentAudioState && newAudioStatus == currentAudioStatus) return

        when (newAudioState) {
            SessionStateControllerAction.FinishConnecting -> {
                when (currentAudioState) {
                    SessionStateControllerAction.Connecting -> {
                        notifyStartSucceeded()
                        notifyAudioClientObserver { observer -> observer.onAudioSessionStarted(false) }
                    }
                    SessionStateControllerAction.Reconnecting -> {
                        meetingStatsCollector.incrementRetryCount()
                        eventAnalyticsController.pushHistory(
                            MeetingHistoryEventName.meetingReconnected
                        )
                        notifyStartSucceeded()
                        notifyAudioClientObserver {
                            observer -> observer.onAudioSessionStarted(true)
                            primaryMeetingPromotionObserver?.onPrimaryMeetingDemotion(MeetingSessionStatus(MeetingSessionStatusCode.AudioInternalServerError))
                                ?: run {
                                    logger.info(TAG, "Primary meeting demotion occurred from audio (on reconnect) but no primary meeting demotion callback is set")
                                }
                            primaryMeetingPromotionObserver = null
                        }
                    }
                    SessionStateControllerAction.FinishConnecting ->
                        when (newAudioStatus) {
                            MeetingSessionStatusCode.OK ->
                                if (currentAudioStatus == MeetingSessionStatusCode.NetworkBecamePoor) {
                                    notifyAudioClientObserver { observer -> observer.onConnectionRecovered() }
                                }
                            MeetingSessionStatusCode.NetworkBecamePoor ->
                                if (currentAudioStatus == MeetingSessionStatusCode.OK) {
                                    meetingStatsCollector.incrementPoorConnectionCount()
                                    notifyAudioClientObserver { observer -> observer.onConnectionBecamePoor() }
                                }
                        }
                }
            }
            SessionStateControllerAction.Reconnecting -> {
                if (currentAudioState == SessionStateControllerAction.FinishConnecting) {
                    notifyAudioClientObserver { observer -> observer.onAudioSessionDropped() }
                }
            }
            SessionStateControllerAction.FinishDisconnecting -> {
                if (currentAudioState == SessionStateControllerAction.Reconnecting) {
                    notifyAudioClientObserver { observer -> observer.onAudioSessionCancelledReconnect() }
                }
            }
            SessionStateControllerAction.Fail -> {
                when (currentAudioState) {
                    SessionStateControllerAction.Connecting,
                    SessionStateControllerAction.FinishConnecting -> {
                        handleOnAudioSessionFailed(newAudioStatus)
                    }
                    SessionStateControllerAction.Reconnecting -> {
                        notifyAudioClientObserver { observer ->
                            observer.onAudioSessionCancelledReconnect()
                        }
                        handleOnAudioSessionFailed(newAudioStatus)
                    }
                }
            }
        }
        currentAudioState = newAudioState
        currentAudioStatus = newAudioStatus
    }

    private fun notifyStartSucceeded() {
        meetingStatsCollector.updateMeetingStartTimeMs()
        eventAnalyticsController.publishEvent(
            EventName.meetingStartSucceeded
        )
    }

    override fun onVolumeStateChange(attendeeUpdates: Array<out AttendeeUpdate>?) {
        if (attendeeUpdates == null) return

        val newAttendeeVolumeMap: Map<String, VolumeUpdate> =
            attendeeUpdates.mapNotNull { attendeeUpdate ->
                VolumeLevel.from(attendeeUpdate.data)?.let {
                    val attendeeInfo = createAttendeeInfo(attendeeUpdate)
                    attendeeUpdate.profileId to VolumeUpdate(
                        attendeeInfo,
                        it
                    )
                }
            }.toMap()

        val changedAttendeeVolumeMap: Map<String, VolumeUpdate> =
            newAttendeeVolumeMap.filter { (key, value) ->
                value.volumeLevel != currentAttendeeVolumeMap[key]?.volumeLevel
            }

        onAttendeesMuteStateChange(changedAttendeeVolumeMap)

        if (changedAttendeeVolumeMap.isNotEmpty()) {
            val volumeUpdates: Array<VolumeUpdate> = changedAttendeeVolumeMap.values.toTypedArray()
            ObserverUtils.notifyObserverOnMainThread(realtimeEventObservers) {
                it.onVolumeChanged(
                    volumeUpdates
                )
            }
        }

        currentAttendeeVolumeMap = newAttendeeVolumeMap
    }

    override fun onSignalStrengthChange(attendeeUpdates: Array<out AttendeeUpdate>?) {
        if (attendeeUpdates == null) return

        val newAttendeeSignalMap: Map<String, SignalUpdate> =
            attendeeUpdates
                .mapNotNull { attendeeUpdate ->
                    SignalStrength.from(attendeeUpdate.data)?.let {
                        val attendeeInfo = createAttendeeInfo(attendeeUpdate)
                        attendeeUpdate.profileId to SignalUpdate(
                            attendeeInfo,
                            it
                        )
                    }
                }.toMap()

        val changedAttendeeSignalMap: Map<String, SignalUpdate> =
            newAttendeeSignalMap.filter { (key, value) ->
                value.signalStrength != currentAttendeeSignalMap[key]?.signalStrength
            }

        if (changedAttendeeSignalMap.isNotEmpty()) {
            val signalUpdates: Array<SignalUpdate> = changedAttendeeSignalMap.values.toTypedArray()
            ObserverUtils.notifyObserverOnMainThread(realtimeEventObservers) {
                it.onSignalStrengthChanged(
                    signalUpdates
                )
            }
        }

        currentAttendeeSignalMap = newAttendeeSignalMap
    }

    override fun onTranscriptEventsReceived(events: Array<out TranscriptEventInternal>?) {
        if (events == null) return

        events.forEach { rawEvent ->
            val event: TranscriptEvent?
            when (rawEvent) {
                is TranscriptionStatusInternal -> {
                    event = TranscriptionStatus(
                        TranscriptionStatusType.from(rawEvent.type.value),
                        rawEvent.eventTimeMs,
                        rawEvent.transcriptionRegion,
                        rawEvent.transcriptionConfiguration,
                        rawEvent.message
                    )
                }
                is TranscriptInternal -> {
                    val results = mutableListOf<TranscriptResult>()
                    rawEvent.results.forEach { rawResult ->
                        val alternatives = mutableListOf<TranscriptAlternative>()
                        rawResult.alternatives.forEach { rawAlternative ->
                            val items = mutableListOf<TranscriptItem>()
                            rawAlternative.items.forEach { rawItem ->
                                val item = TranscriptItem(
                                    TranscriptItemType.from(rawItem.type.value),
                                    rawItem.startTimeMs,
                                    rawItem.endTimeMs,
                                    AttendeeInfo(
                                        rawItem.attendee.attendeeId,
                                        rawItem.attendee.externalUserId
                                    ),
                                    rawItem.content,
                                    rawItem.vocabularyFilterMatch,
                                    rawItem.confidence,
                                    rawItem.stable
                                )
                                items.add(item)
                            }
                            val entities = rawAlternative.entities?.let {
                                it.map { rawEntity ->
                                    TranscriptEntity(
                                        rawEntity.category,
                                        rawEntity.confidence,
                                        rawEntity.content,
                                        rawEntity.startTimeMs,
                                        rawEntity.endTimeMs,
                                        rawEntity.type
                                    )
                                }.toTypedArray() ?: run { null }
                            }
                            val alternative = TranscriptAlternative(
                                items.toTypedArray(),
                                entities,
                                rawAlternative.transcript
                            )
                            alternatives.add(alternative)
                        }
                        val languageIdentification = rawResult.languageIdentification?.let {
                            it.map { rawLanguageIdentification ->
                                TranscriptLanguageWithScore(
                                    rawLanguageIdentification.languageCode,
                                    rawLanguageIdentification.score
                                )
                            }.toTypedArray() ?: run { null }
                        }
                        val result = TranscriptResult(
                            rawResult.resultId,
                            rawResult.channelId,
                            rawResult.isPartial,
                            rawResult.startTimeMs,
                            rawResult.endTimeMs,
                            alternatives.toTypedArray(),
                            rawResult.languageCode,
                            languageIdentification
                        )
                        results.add(result)
                    }
                    event = Transcript(results.toTypedArray())
                } else -> {
                    logger.error(TAG, "Received transcript event in unknown format")
                    event = null
                }
            }

            event?.let { transcriptEvent ->
                ObserverUtils.notifyObserverOnMainThread(transcriptEventObservers) {
                    it.onTranscriptEventReceived(transcriptEvent)
                }
            }
        }
    }

    private fun onAttendeesMuteStateChange(volumesDelta: Map<String, VolumeUpdate>) {
        val mutedAttendeeMap: Map<String, VolumeUpdate> = volumesDelta.filter { (_, value) ->
            value.volumeLevel == VolumeLevel.Muted
        }

        if (mutedAttendeeMap.isNotEmpty()) {
            val mutedAttendeeInfo: Array<AttendeeInfo> =
                mutedAttendeeMap.map { (_, value) -> value.attendeeInfo }.toTypedArray()

            ObserverUtils.notifyObserverOnMainThread(realtimeEventObservers) {
                it.onAttendeesMuted(
                    mutedAttendeeInfo
                )
            }
        }

        val unMutedAttendeeMap = volumesDelta.filter { (key, _) ->
            currentAttendeeVolumeMap[key]?.volumeLevel == VolumeLevel.Muted
        }

        if (unMutedAttendeeMap.isNotEmpty()) {
            val unMutedAttendeeInfo: Array<AttendeeInfo> =
                unMutedAttendeeMap.map { (_, value) -> value.attendeeInfo }.toTypedArray()

            ObserverUtils.notifyObserverOnMainThread(realtimeEventObservers) {
                it.onAttendeesUnmuted(
                    unMutedAttendeeInfo
                )
            }
        }
    }

    override fun onLogMessage(logLevel: Int, message: String?) {
        if (message == null) return

        // Only print error and fatal as the Media team's request to avoid noise for application
        // that has log level set to INFO or higher. All other cases, print as verbose
        if (logLevel == AudioClient.L_ERROR || logLevel == AudioClient.L_FATAL) {
            logger.error(TAG, message)
        } else {
            logger.verbose(TAG, message)
        }
    }

    override fun onMetrics(metrics: IntArray?, values: DoubleArray?) {
        if (metrics == null || values == null) return

        val metricMap = mutableMapOf<Int, Double>()
        (metrics.indices).map { i -> metricMap[metrics[i]] = values[i] }
        clientMetricsCollector.processAudioClientMetrics(metricMap)
    }

    /**
     * This will be called by media library whenever there is attendee presence change
     * There could be duplicate attendee join, but no duplicate left/drop will be provided
     * from media library side.
     */
    override fun onAttendeesPresenceChange(attendeeUpdates: Array<out AttendeeUpdate>?) {
        if (attendeeUpdates == null) return

        // group by attendee status
        val attendeesByStatus = attendeeUpdates
            .groupBy({ AttendeeStatus.from(it.data) }, { createAttendeeInfo(it) })

        attendeesByStatus[AttendeeStatus.Joined]?.let {
            val attendeeJoined = it.minus(currentAttendees)
            if (attendeeJoined.isNotEmpty()) {
                onAttendeesJoin(attendeeJoined.toTypedArray())
                currentAttendees.addAll(attendeeJoined)
            }
        }

        attendeesByStatus[AttendeeStatus.Left]?.let {
            onAttendeesLeft(it.toTypedArray())
            currentAttendees.removeAll(it)
        }

        attendeesByStatus[AttendeeStatus.Dropped]?.let {
            onAttendeesDropped(it.toTypedArray())
            currentAttendees.removeAll(it)
        }
    }

    // Create AttendeeInfo based on AttendeeUpdate, fill up external ID for local attendee
    private fun createAttendeeInfo(attendeeUpdate: AttendeeUpdate): AttendeeInfo {
        val externalUserId =
            if (attendeeUpdate.externalUserId.isEmpty() &&
                (attendeeUpdate.profileId == configuration.credentials.attendeeId)
            ) {
                configuration.credentials.externalUserId
            } else {
                attendeeUpdate.externalUserId
            }
        return AttendeeInfo(
            attendeeUpdate.profileId,
            externalUserId
        )
    }

    private fun onAttendeesJoin(attendeeInfo: Array<AttendeeInfo>) {
        logger.debug(TAG, "Joined: ${attendeeInfo.joinToString(" ")}")
        ObserverUtils.notifyObserverOnMainThread(realtimeEventObservers) {
            it.onAttendeesJoined(
                attendeeInfo
            )
        }
    }

    private fun onAttendeesLeft(attendeeInfo: Array<AttendeeInfo>) {
        logger.debug(TAG, "Left: ${attendeeInfo.joinToString(" ")}")
        ObserverUtils.notifyObserverOnMainThread(realtimeEventObservers) {
            it.onAttendeesLeft(
                attendeeInfo
            )
        }
    }

    private fun onAttendeesDropped(attendeeInfo: Array<AttendeeInfo>) {
        logger.debug(TAG, "Dropped: ${attendeeInfo.joinToString(" ")}")
        ObserverUtils.notifyObserverOnMainThread(realtimeEventObservers) {
            it.onAttendeesDropped(
                attendeeInfo
            )
        }
    }

    override fun onAudioClientPrimaryMeetingEvent(type: Int, status: Int) {
        logger.info(TAG, "Primary meeting event for notified with type $type and status $status")
        val sdkStatus = when (status) {
            AudioClient.AUDIO_CLIENT_PRIMARY_MEETING_OK -> MeetingSessionStatusCode.OK
            AudioClient.AUDIO_SERVER_PRIMARY_MEETING_DISCONNECTED -> MeetingSessionStatusCode.AudioDisconnected
            AudioClient.AUDIO_SERVER_PRIMARY_MEETING_INTERNAL_ERROR -> MeetingSessionStatusCode.AudioInternalServerError
            AudioClient.AUDIO_SERVER_PRIMARY_MEETING_AUTHENTICATION_FAILED -> MeetingSessionStatusCode.AudioAuthenticationRejected
            AudioClient.AUDIO_SERVER_PRIMARY_MEETING_REMOVED_FROM_MEETING -> MeetingSessionStatusCode.AudioDisconnectAudio
            else -> MeetingSessionStatusCode.AudioInternalServerError
        }

        when (type) {
            AudioClient.AUDIO_SERVER_PRIMARY_MEETING_JOIN_ACK -> {
                primaryMeetingPromotionObserver?.onPrimaryMeetingPromotion(MeetingSessionStatus(sdkStatus))
                    ?: run {
                    logger.info(TAG, "Primary meeting promotion completed from audio but no primary meeting promotion callback is set")
                }
            }
            AudioClient.AUDIO_SERVER_PRIMARY_MEETING_LEAVE -> {
                primaryMeetingPromotionObserver?.onPrimaryMeetingDemotion(MeetingSessionStatus(sdkStatus))
                    ?: run {
                        logger.info(TAG, "Primary meeting demotion occurred from audio but no primary meeting demotion callback is set")
                }
                primaryMeetingPromotionObserver = null
            }
        }
    }

    override fun subscribeToAudioClientStateChange(observer: AudioVideoObserver) {
        this.audioClientStateObservers.add(observer)
    }

    override fun unsubscribeFromAudioClientStateChange(observer: AudioVideoObserver) {
        this.audioClientStateObservers.remove(observer)
    }

    override fun subscribeToRealTimeEvents(observer: RealtimeObserver) {
        realtimeEventObservers.add(observer)
    }

    override fun unsubscribeFromRealTimeEvents(observer: RealtimeObserver) {
        realtimeEventObservers.remove(observer)
    }

    override fun subscribeToTranscriptEvent(observer: TranscriptEventObserver) {
        transcriptEventObservers.add(observer)
    }

    override fun unsubscribeFromTranscriptEvent(observer: TranscriptEventObserver) {
        transcriptEventObservers.remove(observer)
    }

    override fun notifyAudioClientObserver(observerFunction: (observer: AudioVideoObserver) -> Unit) {
        ObserverUtils.notifyObserverOnMainThread(audioClientStateObservers, observerFunction)
    }

    private fun toAudioClientState(internalAudioClientState: Int): SessionStateControllerAction {
        return when (internalAudioClientState) {
            AudioClient.AUDIO_CLIENT_STATE_UNKNOWN -> SessionStateControllerAction.Unknown
            AudioClient.AUDIO_CLIENT_STATE_INIT -> SessionStateControllerAction.Init
            AudioClient.AUDIO_CLIENT_STATE_CONNECTING -> SessionStateControllerAction.Connecting
            AudioClient.AUDIO_CLIENT_STATE_CONNECTED -> SessionStateControllerAction.FinishConnecting
            AudioClient.AUDIO_CLIENT_STATE_RECONNECTING -> SessionStateControllerAction.Reconnecting
            AudioClient.AUDIO_CLIENT_STATE_DISCONNECTING -> SessionStateControllerAction.Disconnecting
            AudioClient.AUDIO_CLIENT_STATE_DISCONNECTED_NORMAL -> SessionStateControllerAction.FinishDisconnecting
            AudioClient.AUDIO_CLIENT_STATE_FAILED_TO_CONNECT,
            AudioClient.AUDIO_CLIENT_STATE_DISCONNECTED_ABNORMAL,
            AudioClient.AUDIO_CLIENT_STATE_SERVER_HUNGUP -> SessionStateControllerAction.Fail
            else -> SessionStateControllerAction.Unknown
        }
    }

    private fun toAudioStatus(internalAudioStatus: Int): MeetingSessionStatusCode? {
        return when (internalAudioStatus) {
            AudioClient.AUDIO_CLIENT_OK -> MeetingSessionStatusCode.OK
            AudioClient.AUDIO_CLIENT_STATUS_NETWORK_IS_NOT_GOOD_ENOUGH_FOR_VOIP -> MeetingSessionStatusCode.NetworkBecamePoor
            AudioClient.AUDIO_CLIENT_ERR_SERVER_HUNGUP -> MeetingSessionStatusCode.AudioDisconnected
            AudioClient.AUDIO_CLIENT_ERR_JOINED_FROM_ANOTHER_DEVICE -> MeetingSessionStatusCode.AudioJoinedFromAnotherDevice
            AudioClient.AUDIO_CLIENT_ERR_INTERNAL_SERVER_ERROR -> MeetingSessionStatusCode.AudioInternalServerError
            AudioClient.AUDIO_CLIENT_ERR_AUTH_REJECTED -> MeetingSessionStatusCode.AudioAuthenticationRejected
            AudioClient.AUDIO_CLIENT_ERR_CALL_AT_CAPACITY -> MeetingSessionStatusCode.AudioCallAtCapacity
            AudioClient.AUDIO_CLIENT_ERR_SERVICE_UNAVAILABLE -> MeetingSessionStatusCode.AudioServiceUnavailable
            AudioClient.AUDIO_CLIENT_ERR_SHOULD_DISCONNECT_AUDIO -> MeetingSessionStatusCode.AudioDisconnectAudio
            AudioClient.AUDIO_CLIENT_ERR_CALL_ENDED -> MeetingSessionStatusCode.AudioCallEnded
            AudioClient.AUDIO_CLIENT_ERR_INPUT_DEVICE_NOT_RESPONDING -> MeetingSessionStatusCode.AudioInputDeviceNotResponding
            AudioClient.AUDIO_CLIENT_ERR_OUTPUT_DEVICE_NOT_RESPONDING -> MeetingSessionStatusCode.AudioOutputDeviceNotResponding
            else -> null
        }
    }

    private fun handleOnAudioSessionFailed(statusCode: MeetingSessionStatusCode?) {
        if (audioClient != null) {
            notifyFailed(statusCode)
            GlobalScope.launch {
                audioClient?.stopSession()
                DefaultAudioClientController.audioClientState = AudioClientState.STOPPED
                notifyAudioClientObserver { observer ->
                    observer.onAudioSessionStopped(MeetingSessionStatus(statusCode))
                }
            }
        } else {
            logger.error(TAG, "Failed to stop audio session since audioClient is null")
        }
    }

    private fun notifyFailed(statusCode: MeetingSessionStatusCode?) {
        val attributes = statusCode?.let {
            mutableMapOf(
                EventAttributeName.meetingStatus to statusCode,
                EventAttributeName.meetingErrorMessage to statusCode.toString()
            )
        }
        eventAnalyticsController.publishEvent(EventName.meetingFailed, attributes)
        meetingStatsCollector.resetMeetingStats()
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.contentshare

import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareObserver
import com.amazonaws.services.chime.sdk.meetings.audiovideo.video.LocalVideoConfiguration
import com.amazonaws.services.chime.sdk.meetings.audiovideo.video.VideoFrame
import com.amazonaws.services.chime.sdk.meetings.audiovideo.video.VideoSource

/**
 * [ContentShareVideoClientController] uses a separate video client for content share video related
 * functionality.
 */
interface ContentShareVideoClientController {
    /**
     * Start to share video with a provided custom [VideoSource] which can be used to provide custom
     * [VideoFrame]s to be transmitted to remote clients. This will call [VideoSource.addVideoSink]
     * on the provided source and [VideoSource.removeVideoSink] on the previously provided source.
     *
     * Calling this function repeatedly will replace the previous [VideoSource] as the one being
     * transmitted.
     *
     * @param videoSource: [VideoSource] - The source of video frames to be sent to other clients.
     */
    fun startVideoShare(videoSource: VideoSource)

    /**
     * Start to share video with configuration and a provided custom [VideoSource] which can be
     * used to provide custom [VideoFrame]s to be transmitted to remote clients.
     * This will call [VideoSource.addVideoSink] on the provided source
     * and [VideoSource.removeVideoSink] on the previously provided source.
     *
     * Calling this function repeatedly will replace the previous [VideoSource] as the one being
     * transmitted, will update the content share configuration, e.g. maxBitRateKbps.
     *
     * @param videoSource: [VideoSource] - The source of video frames to be sent to other clients.
     * @param config: [LocalVideoConfiguration] - The configuration of emitted video stream, e.g. maxBitRateKbps
     */
    fun startVideoShare(videoSource: VideoSource, config: LocalVideoConfiguration)

    /**
     * Stop sending video to remote clients.
     */
    fun stopVideoShare()

    /**
     * Subscribe to video client state and connection events with an [ContentShareObserver]
     *
     * @param observer: [ContentShareObserver] - The observer to be notified for events.
     */
    fun subscribeToVideoClientStateChange(observer: ContentShareObserver)

    /**
     * Unsubscribe from video client state and connection events by removing the specified [ContentShareObserver]
     *
     * @param observer: [ContentShareObserver] - The observer to be removed for events.
     */
    fun unsubscribeFromVideoClientStateChange(observer: ContentShareObserver)
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.contentshare

import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareObserver
import com.xodee.client.video.VideoClientDelegate
import com.xodee.client.video.VideoClientLogListener

/**
 * [ContentShareVideoClientObserver] handles all callbacks related to the separate video client
 * for content share and allows higher level components to observe the lower level Video Client events.
 */
interface ContentShareVideoClientObserver : VideoClientDelegate, VideoClientLogListener {

    /**
     * Subscribe to video client state and connection events with an [ContentShareObserver]
     *
     * @param observer: [ContentShareObserver] - The observer to subscribe to events with.
     */
    fun subscribeToVideoClientStateChange(observer: ContentShareObserver)

    /**
     * Unsubscribe from video client state and connection events by removing the specified [ContentShareObserver]
     *
     * @param observer: [ContentShareObserver] - The observer to unsubscribe from events with.
     */
    fun unsubscribeFromVideoClientStateChange(observer: ContentShareObserver)
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.contentshare

import android.content.Context
import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareObserver
import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareStatus
import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareStatusCode
import com.amazonaws.services.chime.sdk.meetings.audiovideo.video.LocalVideoConfiguration
import com.amazonaws.services.chime.sdk.meetings.audiovideo.video.VideoSource
import com.amazonaws.services.chime.sdk.meetings.audiovideo.video.gl.EglCore
import com.amazonaws.services.chime.sdk.meetings.audiovideo.video.gl.EglCoreFactory
import com.amazonaws.services.chime.sdk.meetings.internal.utils.AppInfoUtil
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ConcurrentSet
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ObserverUtils
import com.amazonaws.services.chime.sdk.meetings.internal.video.VideoClientFactory
import com.amazonaws.services.chime.sdk.meetings.internal.video.VideoSourceAdapter
import com.amazonaws.services.chime.sdk.meetings.session.MeetingSessionConfiguration
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger
import com.xodee.client.video.VideoClient
import com.xodee.client.video.VideoClientConfig
import com.xodee.client.video.VideoClientConfigBuilder

class DefaultContentShareVideoClientController(
    private val context: Context,
    private val logger: Logger,
    private val contentShareVideoClientObserver: ContentShareVideoClientObserver,
    private val configuration: MeetingSessionConfiguration,
    private val videoClientFactory: VideoClientFactory,
    private val eglCoreFactory: EglCoreFactory
) : ContentShareVideoClientController {
    private val observers = ConcurrentSet.createConcurrentSet<ContentShareObserver>()
    private val videoSourceAdapter = VideoSourceAdapter()
    private var videoClient: VideoClient? = null
    private var eglCore: EglCore? = null
    private var isSharing = false

    private val TAG = "DefaultContentShareVideoClientController"

    private val VIDEO_CLIENT_FLAG_ENABLE_SEND_SIDE_BWE = 1 shl 5
    private val VIDEO_CLIENT_FLAG_ENABLE_USE_HW_DECODE_AND_RENDER = 1 shl 6
    private val VIDEO_CLIENT_FLAG_ENABLE_TWO_SIMULCAST_STREAMS = 1 shl 12
    private val VIDEO_CLIENT_FLAG_DISABLE_SIMULCAST_P2P = 1 shl 14
    private val VIDEO_CLIENT_FLAG_DISABLE_CAPTURER = 1 shl 20
    private val VIDEO_CLIENT_FLAG_IS_CONTENT = 1 shl 23
    private val VIDEO_CLIENT_FLAG_ENABLE_INBAND_TURN_CREDS = 1 shl 26

    override fun startVideoShare(videoSource: VideoSource) {
        startVideoShare(videoSource, LocalVideoConfiguration())
    }

    override fun startVideoShare(videoSource: VideoSource, config: LocalVideoConfiguration) {
        // Start the given content share source
        if (eglCore == null) {
            logger.debug(TAG, "Creating EGL core")
            eglCore = eglCoreFactory.createEglCore()
        }

        // Start video client only when not currently sharing
        if (!isSharing) {
            if (videoClient == null) {
                initializeVideoClient()
            }

            if (!startVideoClient()) {
                ObserverUtils.notifyObserverOnMainThread(observers) {
                    it.onContentShareStopped(
                        ContentShareStatus(
                            ContentShareStatusCode.VideoServiceFailed
                        )
                    )
                }
                return
            }
        }

        logger.debug(TAG, "Setting external video source to content share source")
        videoSourceAdapter.source = videoSource
        videoClient?.setExternalVideoSource(videoSourceAdapter, eglCore?.eglContext)
        logger.debug(TAG, "Setting sending to true")
        videoClient?.setSending(true)
        isSharing = true
        config.safeMaxBitRateKbps.let {
            if (it > 0) videoClient?.setMaxBitRateKbps(it)
        }
    }

    private fun initializeVideoClient() {
        logger.info(TAG, "Initializing content share video client")
        AppInfoUtil.initializeVideoClientAppDetailedInfo(context)
        // Thread safe operation, can be called multiple times
        VideoClient.javaInitializeGlobals(context)
        videoClient = videoClientFactory.getVideoClient(contentShareVideoClientObserver)
        // Content share is send only
        videoClient?.setReceiving(false)
    }

    private fun startVideoClient(): Boolean {
        logger.info(TAG, "Starting content share video client for content share")
        var flag = 0
        flag = flag or VIDEO_CLIENT_FLAG_ENABLE_SEND_SIDE_BWE
        flag = flag or VIDEO_CLIENT_FLAG_ENABLE_USE_HW_DECODE_AND_RENDER
        flag = flag or VIDEO_CLIENT_FLAG_ENABLE_TWO_SIMULCAST_STREAMS
        flag = flag or VIDEO_CLIENT_FLAG_DISABLE_SIMULCAST_P2P
        flag = flag or VIDEO_CLIENT_FLAG_DISABLE_CAPTURER
        flag = flag or VIDEO_CLIENT_FLAG_IS_CONTENT
        flag = flag or VIDEO_CLIENT_FLAG_ENABLE_INBAND_TURN_CREDS

        val videoClientConfig: VideoClientConfig = VideoClientConfigBuilder()
            .setMeetingId(configuration.meetingId)
            .setToken(configuration.credentials.joinToken)
            .setAudioHostUrl(configuration.urls.audioHostURL)
            .setFlags(flag)
            .setSharedEglContext(eglCore?.eglContext)
            .setSignalingUrl(configuration.urls.signalingURL)
            .createVideoClientConfig()
        val result = videoClient?.start(videoClientConfig) ?: false
        logger.info(TAG, "Content share video client start result: $result")
        return result
    }

    override fun stopVideoShare() {
        logger.info(TAG, "Stopping content share video client")
        videoClient?.javaStopService()
        videoClient?.destroy()
        videoClient = null

        isSharing = false
        eglCore?.release()
        eglCore = null
    }

    override fun subscribeToVideoClientStateChange(observer: ContentShareObserver) {
        observers.add(observer)
        contentShareVideoClientObserver.subscribeToVideoClientStateChange(observer)
    }

    override fun unsubscribeFromVideoClientStateChange(observer: ContentShareObserver) {
        observers.remove(observer)
        contentShareVideoClientObserver.unsubscribeFromVideoClientStateChange(observer)
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.contentshare

import android.content.Context
import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareObserver
import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareStatus
import com.amazonaws.services.chime.sdk.meetings.audiovideo.contentshare.ContentShareStatusCode
import com.amazonaws.services.chime.sdk.meetings.internal.metric.ClientMetricsCollector
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ConcurrentSet
import com.amazonaws.services.chime.sdk.meetings.internal.utils.DNSServerUtils
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ObserverUtils
import com.amazonaws.services.chime.sdk.meetings.internal.utils.TURNRequestUtils
import com.amazonaws.services.chime.sdk.meetings.internal.video.TURNCredentials
import com.amazonaws.services.chime.sdk.meetings.internal.video.TURNRequestParams
import com.amazonaws.services.chime.sdk.meetings.session.URLRewriter
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger
import com.xodee.client.audio.audioclient.AudioClient
import com.xodee.client.video.RemoteVideoSourceInternal
import com.xodee.client.video.VideoClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class DefaultContentShareVideoClientObserver(
    private val context: Context,
    private val logger: Logger,
    private val turnRequestParams: TURNRequestParams,
    private val clientMetricsCollector: ClientMetricsCollector,
    private val urlRewriter: URLRewriter
) : ContentShareVideoClientObserver {
    private val TAG = "DefaultContentShareVideoClientObserver"

    private val contentShareObservers = ConcurrentSet.createConcurrentSet<ContentShareObserver>()

    private val uiScope = CoroutineScope(Dispatchers.Main)

    override fun subscribeToVideoClientStateChange(observer: ContentShareObserver) {
        contentShareObservers.add(observer)
    }

    override fun unsubscribeFromVideoClientStateChange(observer: ContentShareObserver) {
        contentShareObservers.remove(observer)
    }

    override fun isConnecting(client: VideoClient?) {
        logger.debug(TAG, "content share video client is connecting")
    }

    override fun didConnect(client: VideoClient?, controlStatus: Int) {
        logger.debug(TAG, "content share video client is connected")
        ObserverUtils.notifyObserverOnMainThread(contentShareObservers) {
            it.onContentShareStarted()
        }
    }

    override fun didFail(client: VideoClient?, status: Int, controlStatus: Int) {
        logger.info(TAG, "content share video client is failed with $controlStatus")
        resetContentShareVideoClientMetrics()
        ObserverUtils.notifyObserverOnMainThread(contentShareObservers) {
            it.onContentShareStopped(
                ContentShareStatus(
                    ContentShareStatusCode.VideoServiceFailed
                )
            )
        }
    }

    override fun didStop(client: VideoClient?) {
        logger.info(TAG, "content share video client is stopped")
        resetContentShareVideoClientMetrics()
        ObserverUtils.notifyObserverOnMainThread(contentShareObservers) {
            it.onContentShareStopped(
                ContentShareStatus(
                    ContentShareStatusCode.OK
                )
            )
        }
    }

    override fun requestTurnCreds(client: VideoClient?) {
        logger.info(TAG, "requestTurnCreds")
        uiScope.launch {
            val turnResponse: TURNCredentials? =
                TURNRequestUtils.doTurnRequest(turnRequestParams, logger)
            with(turnResponse) {
                val isActive = client?.isActive ?: false
                if (this != null && isActive) {
                    val newUris = uris.map { url ->
                        url?.let {
                            urlRewriter(it)
                        }
                    }.toTypedArray()
                    client?.updateTurnCredentials(
                        username,
                        password,
                        ttl,
                        newUris,
                        turnRequestParams.signalingUrl,
                        VideoClient.VideoClientTurnStatus.VIDEO_CLIENT_TURN_FEATURE_ON
                    )
                } else {
                    client?.updateTurnCredentials(
                        null,
                        null,
                        null,
                        null,
                        null,
                        VideoClient.VideoClientTurnStatus.VIDEO_CLIENT_TURN_STATUS_CCP_FAILURE
                    )
                }
            }
        }
    }

    override fun getAvailableDnsServers(): Array<String> {
        return DNSServerUtils.getAvailableDnsServers(context, logger)
    }

    override fun onCameraChanged() {
        // No implementation since we don't use default camera for content share
    }

    override fun onMetrics(metrics: IntArray?, values: DoubleArray?) {
        if (metrics == null || values == null) return

        val metricMap = mutableMapOf<Int, Double>()
        (metrics.indices).map { i -> metricMap[metrics[i]] = values[i] }
        clientMetricsCollector.processContentShareVideoClientMetrics(metricMap)
    }

    private fun resetContentShareVideoClientMetrics() {
        clientMetricsCollector.processContentShareVideoClientMetrics(emptyMap())
    }

    override fun cameraSendIsAvailable(client: VideoClient?, available: Boolean) {
        // No implementation since we don't use default camera for content share
    }

    override fun didReceiveFrame(
        client: VideoClient?,
        frame: Any?,
        profileId: String?,
        displayId: Int,
        pauseType: Int,
        videoId: Int
    ) {
        // No implementation since content share is send only and we don't use this path for local rendering
    }

    override fun pauseRemoteVideo(client: VideoClient?, display_id: Int, pause: Boolean) {
        // No implementation since content share is send only and we don't get any remote video
    }

    override fun onLogMessage(logLevel: Int, message: String?) {
        if (message == null) return
        // Only print error and fatal as the Media team's request to avoid noise for application
        // that has log level set to INFO or higher. All other cases, print as verbose
        if (logLevel == AudioClient.L_ERROR || logLevel == AudioClient.L_FATAL) {
            logger.error(TAG, message)
        } else {
            logger.verbose(TAG, message)
        }
    }

    override fun onTurnURIsReceived(uris: List<String>): List<String> {
        return uris.map(urlRewriter)
    }

    override fun onRemoteVideoSourceAvailable(sources: Array<RemoteVideoSourceInternal>?) {
        // Content is receive only
    }

    override fun onRemoteVideoSourceUnavailable(sources: Array<RemoteVideoSourceInternal>?) {
        // Content is receive only
    }

    override fun onPrimaryMeetingPromotion(status: Int) {
        // Not relevant, content for promoted attendees is automatically promoted itself
    }

    override fun onPrimaryMeetingDemotion(status: Int) {
        // Not relevant, content for promoted attendees can rely on main attendee's callback
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion

interface DirtyEventDao {
    /**
     * List dirty meeting events items which includes all the fields in the table.
     *
     * @param size: [Int] - size to query
     * @return list of dirty meeting event items from database
     */
    fun listDirtyMeetingEventItems(size: Int): List<DirtyMeetingEventItem>

    /**
     * Delete dirty events by given ids.
     *
     * @param ids: [List<String>] - ids of dirty events
     * @return number of events removed or -1 if delete failed
     */
    fun deleteDirtyEventsByIds(ids: List<String>): Int

    /**
     * Insert multiple dirty meeting events.
     *
     * @param dirtyEvents: [List<DirtyMeetingEventItem>] - List of DirtyMeetingEventItem
     * @return whether insertion was successful or not
     */
    fun insertDirtyMeetingEventItems(dirtyEvents: List<DirtyMeetingEventItem>): Boolean
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion

import java.util.UUID

/**
 * [DirtyMeetingEventItem] is a data type of an entry inside DirtyEvents Sqlite table.
 *
 * @property id unique id of the database entry
 * @property data data/meeting event associated with the entry
 * @property ttl lifetime of that entry that will be checked when ingestion service runs
 */
data class DirtyMeetingEventItem(
    val id: String = UUID.randomUUID().toString(),
    val data: SDKEvent,
    val ttl: Long
)


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion

interface EventDao {
    /**
     * List meeting events items which includes all the fields in the table.
     *
     * @param size: [Int] - size of events to list
     * @return list of [MeetingEventItem]
     */
    fun listMeetingEventItems(size: Int): List<MeetingEventItem>

    /**
     *  Delete meeting events by given ids.
     *
     * @param ids: [List<String>] - list of ids to remove
     * @return number of events removed or -1 if delete failed
     */
    fun deleteMeetingEventsByIds(ids: List<String>): Int

    /**
     * Insert a meeting event item.
     *
     * @param event: [MeetingEventItem] - meeting event to insert
     * @return whether insertion is successful or not
     */
    fun insertMeetingEvent(event: MeetingEventItem): Boolean
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion

import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonDeserializer
import com.google.gson.reflect.TypeToken

/**
 * EventTypeConverters facilitate the conversion on some common event types
 */
class EventTypeConverters(private val logger: Logger) {
    private val TAG = "EventTypeConverters"
    private val gson: Gson

    init {
        val deserializer: JsonDeserializer<SDKEvent> =
            JsonDeserializer { json, _, _ ->
                val jsonObject = json.asJsonObject

                var name = ""
                var attributes: Map<String, Any>? = null

                try {
                    name = jsonObject["name"].asString
                } catch (exception: Exception) {
                    logger.error(TAG, "Unable to deserialize name $exception")
                }

                try {
                    val attributeType = object : TypeToken<Map<String, Any>>() {}.type
                    attributes = Gson().fromJson(jsonObject["eventAttributes"], attributeType)
                } catch (exception: Exception) {
                    logger.error(TAG, "Unable to deserialize eventAttributes $exception")
                }
                SDKEvent(name, attributes ?: mapOf())
            }

        val gsonBuilder = GsonBuilder()
        gsonBuilder.registerTypeAdapter(SDKEvent::class.java, deserializer)
        gson = gsonBuilder.create()
    }

    fun toMeetingEvent(data: String): SDKEvent = gson.fromJson(data, SDKEvent::class.java)
    fun fromMeetingEvent(event: SDKEvent): String = gson.toJson(event)
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion

import com.amazonaws.services.chime.sdk.meetings.analytics.EventAttributeName
import com.amazonaws.services.chime.sdk.meetings.ingestion.IngestionConfiguration
import com.amazonaws.services.chime.sdk.meetings.ingestion.IngestionEvent
import com.amazonaws.services.chime.sdk.meetings.ingestion.IngestionMetadata
import com.amazonaws.services.chime.sdk.meetings.ingestion.IngestionPayload
import com.amazonaws.services.chime.sdk.meetings.ingestion.IngestionRecord
import com.amazonaws.services.chime.sdk.meetings.internal.utils.EventAttributesUtils

// When sending a batch, server accepts the data as
// "metadata": { "meetingId": "meetingId0" },
// "events": [
//   {
//      "metadata": { "meetingId": "meetingId2" } // This overrides record-level metadata
//      "type": "Meet",
//      "v": 1,
//      "payloads": []
//   },
//   {
//      "metadata": { "meetingId": "meetingId1" } // This overrides record-level metadata
//      "type": "Meet",
//      "v": 1,
//      "payloads": []
//   },
// ]
// This is to group events so that each event contains metadata to override the record-level metadata
// in case they are different.
object IngestionEventConverter {
    private const val NAME_KEY = "name"
    private const val TIMESTAMP_KEY = "ts"
    private const val ID_KEY = "id"
    private const val TTL_KEY = "ttl"

    fun fromDirtyMeetingEventItems(items: List<DirtyMeetingEventItem>, ingestionConfiguration: IngestionConfiguration): IngestionRecord {
        if (items.isEmpty()) {
            return IngestionRecord(mutableMapOf(), listOf())
        }

        val dirtyMeetingEventsByMeetingId: Map<String, List<DirtyMeetingEventItem>> =
            items.groupBy {
                it.data.eventAttributes[EventAttributeName.meetingId.name] as? String ?: ""
            }

        val eventMetadataKeys = ingestionConfiguration.clientConfiguration.metadataAttributes.keys
        val ingestionEvents: List<IngestionEvent> = dirtyMeetingEventsByMeetingId.map {
            IngestionEvent(
                type = ingestionConfiguration.clientConfiguration.tag,
                metadata = toIngestionMetadata(it.value.first(), eventMetadataKeys),
                payloads = it.value.map { meetingEventItem ->
                    toIngestionPayload(
                        meetingEventItem, eventMetadataKeys
                    )
                })
        }

        val rootMetadata: IngestionMetadata = EventAttributesUtils.getIngestionMetadata(ingestionConfiguration)

        return IngestionRecord(rootMetadata, ingestionEvents)
    }

    // Need to have different name due to Kotlin name collision on List
    fun fromMeetingEventItems(items: List<MeetingEventItem>, ingestionConfiguration: IngestionConfiguration): IngestionRecord {
        if (items.isEmpty()) {
            return IngestionRecord(mutableMapOf(), listOf())
        }

        val meetingEventsByMeetingId: Map<String, List<MeetingEventItem>> =
            items.groupBy {
                it.data.eventAttributes[EventAttributeName.meetingId.name] as? String ?: ""
            }

        val eventMetadataKeys = ingestionConfiguration.clientConfiguration.metadataAttributes.keys

        val ingestionEvents = meetingEventsByMeetingId.map {
            IngestionEvent(
                type = ingestionConfiguration.clientConfiguration.tag,
                metadata = toIngestionMetadata(it.value.first(), eventMetadataKeys),
                payloads = it.value.map { meetingEventItem ->
                    toIngestionPayload(
                        meetingEventItem, eventMetadataKeys
                    )
                })
        }

        val rootMetadata: IngestionMetadata = EventAttributesUtils.getIngestionMetadata(ingestionConfiguration)
        return IngestionRecord(rootMetadata, ingestionEvents)
    }

    private fun toIngestionMetadata(eventItem: MeetingEventItem, metadataAttributeKeys: Set<String>): IngestionMetadata {
        return toIngestionMetadata(eventItem.data.eventAttributes, metadataAttributeKeys)
    }

    private fun toIngestionMetadata(dirtyEventItem: DirtyMeetingEventItem, metadataAttributeKeys: Set<String>): IngestionMetadata {
        return toIngestionMetadata(dirtyEventItem.data.eventAttributes, metadataAttributeKeys)
    }

    private fun toIngestionMetadata(eventAttributes: Map<String, Any>, metadataAttributeKeys: Set<String>): IngestionMetadata {
        val ingestionMetadata = mutableMapOf<String, Any>()

        // These converts to some of overridden ingestion meta data
        for (attributeName in metadataAttributeKeys) {
            val value = eventAttributes[attributeName]
            value?.let {
                ingestionMetadata[attributeName] = it
            }
        }

        return ingestionMetadata
    }

    private fun toIngestionPayload(event: MeetingEventItem, metadataAttributeKeys: Set<String>): IngestionPayload {
        val payload = mutableMapOf(
            NAME_KEY to event.data.name,
            TIMESTAMP_KEY to (event.data.eventAttributes[EventAttributeName.timestampMs.name] as Double).toLong(),
            ID_KEY to event.id
        )
        // Filter out metadata attributes since this is not needed for payload
        event.data.eventAttributes.filterNot { metadataAttributeKeys.contains(it.key) }
            .forEach {
                payload[it.key] = it.value
            }

        return payload
    }

    private fun toIngestionPayload(dirtyEvent: DirtyMeetingEventItem, metadataAttributeKeys: Set<String>): IngestionPayload {
        val payload = mutableMapOf(
            NAME_KEY to dirtyEvent.data.name,
            TTL_KEY to dirtyEvent.ttl,
            TIMESTAMP_KEY to (dirtyEvent.data.eventAttributes[EventAttributeName.timestampMs.name] as Double).toLong(),
            ID_KEY to dirtyEvent.id
        )
        // Filter out metadata attributes since this is not needed for payload
        dirtyEvent.data.eventAttributes.filterNot { metadataAttributeKeys.contains(it.key) }
            .forEach {
                payload[it.key] = it.value
            }

        return payload
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion

import java.util.UUID

/**
 * [MeetingEventItem] is a data type of an entry inside Events SQLite table.
 *
 * @property id unique id of the database entry
 * @property data data/meeting event associated with the entry
 */
data class MeetingEventItem(
    val id: String = UUID.randomUUID().toString(),
    val data: SDKEvent
)


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion

import com.amazonaws.services.chime.sdk.meetings.analytics.EventAttributes
import com.amazonaws.services.chime.sdk.meetings.analytics.EventName
import com.amazonaws.services.chime.sdk.meetings.analytics.MeetingHistoryEventName
import com.amazonaws.services.chime.sdk.meetings.analytics.toStringKeyMap

class SDKEvent(
    val name: String,
    val eventAttributes: Map<String, Any>
) {
    constructor(eventName: EventName, eventAttributes: EventAttributes) : this(eventName.name, eventAttributes.toStringKeyMap())
    constructor(eventName: MeetingHistoryEventName, eventAttributes: EventAttributes) : this(eventName.name, eventAttributes.toStringKeyMap())

    fun putAttributes(attributes: Map<String, Any>): SDKEvent {
        val updatedEventAttributes = eventAttributes.toMutableMap()
        updatedEventAttributes.putAll(attributes)
        return SDKEvent(name, updatedEventAttributes)
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion.database

import android.content.ContentValues

interface DatabaseManager {
    /**
     * Create Table based on the primary keys and other keys defined.
     *
     * @param table: [DatabaseTable] - Table to create
     * @return whether creating table was successful or not
     */
    fun createTable(table: DatabaseTable): Boolean

    /**
     * Drop/Delete Table based on given table name
     *
     * @param tableName: [String] - Table name to drop
     * @return whether dropping table was successful or not
     */
    fun dropTable(tableName: String): Boolean

    /**
     * Query table based on size. Since the order is not specified, it will most likely get based on
     * insertion order.
     *
     * @param tableName: [String] - Name of table to query
     * @param size: [Int] - size to query. It will return fewer if table contains fewer.
     * @return list of map whose key is column name and value is value of that column.
     */
    fun query(tableName: String, size: Int? = 1000): List<Map<String, Any?>>

    /**
     * Insert a list of items into the table.
     *
     * @param tableName: [String] - name of table
     * @param contentValues: [List<ContentValues>] - values to insert
     * @return whether insertion was successful or not.
     */
    fun insert(tableName: String, contentValues: List<ContentValues>): Boolean

    /**
     * Delete items based on the table name, given primary key, and values.
     *
     * @param tableName: [String] - name of table
     * @param keyName: [String] - column name of primary key
     * @param ids: [List<String>] - ids to delete
     * @return number of rows deleted. Returns -1 if there is error
     */
    fun delete(tableName: String, keyName: String, ids: List<String>): Int

    /**
     * Clear the table.
     *
     * @param tableName: [String] - name of table
     * @return whether clear was successful or not.
     */
    fun clear(tableName: String): Boolean
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion.database

/**
 * Database Table that defines the columns and the primary key.
 * Currently foreign keys are not supported.
 * Only single primary key is supported.
 */
interface DatabaseTable {
    /**
     * Name of database table.
     */
    val tableName: String

    /**
     * Columns other than primary keys.
     * Key would be column name and value would be type.
     * For example, mapOf("data" to "TEXT")
     */
    val columns: Map<String, String>

    /**
     * A primary key for the database
     */
    val primaryKey: Pair<String, String>
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion.database

import android.content.ContentValues
import com.amazonaws.services.chime.sdk.meetings.internal.ingestion.DirtyEventDao
import com.amazonaws.services.chime.sdk.meetings.internal.ingestion.DirtyMeetingEventItem
import com.amazonaws.services.chime.sdk.meetings.internal.ingestion.EventTypeConverters
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger

class DirtyEventSQLiteDao(
    private val databaseManager: DatabaseManager,
    private val logger: Logger,
    private val eventTypeConverter: EventTypeConverters
) : DirtyEventDao, DatabaseTable {
    override val tableName = "DirtyEvents"
    override val columns: Map<String, String>
        get() = mapOf(dataColumnName to dataColumnType, ttlColumnName to ttlColumnType)
    override val primaryKey: Pair<String, String>
        get() = (idColumnName to idColumnType)

    private val TAG = "DirtyEventSQLiteDao"
    private val idColumnName = "id"
    private val dataColumnName = "data"
    private val ttlColumnName = "ttl"
    private val idColumnType = "TEXT"
    private val dataColumnType = "TEXT NOT NULL"
    private val ttlColumnType = "INTEGER NOT NULL"
    init {
        databaseManager.createTable(this)
    }

    override fun listDirtyMeetingEventItems(size: Int): List<DirtyMeetingEventItem> {
        val retrievedDataList = databaseManager.query(tableName, size)

        return retrievedDataList.map { retrievedData ->
            DirtyMeetingEventItem(
                retrievedData[idColumnName] as String,
                eventTypeConverter.toMeetingEvent(retrievedData[dataColumnName] as String),
                retrievedData[ttlColumnName] as Long
            )
        }
    }

    override fun deleteDirtyEventsByIds(ids: List<String>): Int {
        return databaseManager.delete(tableName, idColumnName, ids)
    }

    override fun insertDirtyMeetingEventItems(dirtyEvents: List<DirtyMeetingEventItem>): Boolean {
        return databaseManager.insert(tableName, dirtyEvents.map { dirtyEvent ->
            ContentValues().apply {
                put(idColumnName, dirtyEvent.id)
                put(dataColumnName, eventTypeConverter.fromMeetingEvent(dirtyEvent.data))
                put(ttlColumnName, dirtyEvent.ttl)
            }
        })
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion.database

import android.content.ContentValues
import com.amazonaws.services.chime.sdk.meetings.internal.ingestion.EventDao
import com.amazonaws.services.chime.sdk.meetings.internal.ingestion.EventTypeConverters
import com.amazonaws.services.chime.sdk.meetings.internal.ingestion.MeetingEventItem
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger

class EventSQLiteDao(
    private val databaseManager: DatabaseManager,
    private val logger: Logger,
    private val eventTypeConverter: EventTypeConverters
) : EventDao, DatabaseTable {
    override val tableName = "Events"
    override val columns: Map<String, String>
        get() = mapOf(
            dataColumnName to dataColumnType
        )
    override val primaryKey: Pair<String, String>
        get() = (idColumnName to idColumnType)
    private val TAG = "EventSQLiteDao"

    private val idColumnName = "id"
    private val dataColumnName = "data"
    private val idColumnType = "TEXT"
    private val dataColumnType = "TEXT NOT NULL"

    init {
        databaseManager.createTable(this)
    }

    override fun listMeetingEventItems(size: Int): List<MeetingEventItem> {
        val retrievedDataList = databaseManager.query(tableName, size)

        return retrievedDataList.map { retrievedData ->
            MeetingEventItem(
                retrievedData[idColumnName] as String,
                eventTypeConverter.toMeetingEvent(retrievedData[dataColumnName] as String)
            )
        }
    }

    override fun insertMeetingEvent(event: MeetingEventItem): Boolean {
        val values = ContentValues().apply {
            put(idColumnName, event.id)
            put(dataColumnName, eventTypeConverter.fromMeetingEvent(event.data))
        }

        return databaseManager.insert(tableName, listOf(values))
    }

    override fun deleteMeetingEventsByIds(ids: List<String>): Int {
        return databaseManager.delete(tableName, idColumnName, ids)
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.ingestion.database

import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.Cursor.FIELD_TYPE_BLOB
import android.database.Cursor.FIELD_TYPE_FLOAT
import android.database.Cursor.FIELD_TYPE_INTEGER
import android.database.Cursor.FIELD_TYPE_NULL
import android.database.Cursor.FIELD_TYPE_STRING
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger

const val DATABASE_NAME = "AmazonChimeSDKEvent.db"
const val IN_MEMORY_DATABASE = ":memory:"

class SQLiteDatabaseManager(
    context: Context,
    private val logger: Logger,
    databaseName: String = DATABASE_NAME
) :
    // Context, DatabaseName, Factory (We have no factory), version (It will be 1 for first version)
    SQLiteOpenHelper(context, databaseName, null, 1), DatabaseManager {
    private val TAG = "SQLiteDatabaseManager"
    private val invalidDeleteCount = -1

    override fun onCreate(database: SQLiteDatabase?) {
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        // We don't do anything on upgrade. We'll create different table
    }

    override fun createTable(table: DatabaseTable): Boolean {
        val builder = StringBuilder()
        // TODO: sanitize tableName
        builder.append("CREATE TABLE IF NOT EXISTS ${table.tableName} ")

        val primaryKeyList =
            listOf("${table.primaryKey.first} ${table.primaryKey.second} PRIMARY KEY")
        val columnList =
            table.columns.entries.map { entry -> "${entry.key} ${entry.value}" }.toList()

        val keyList = primaryKeyList + columnList
        builder.append("(${keyList.joinToString(",")})")
        return try {
            writableDatabase.execSQL(builder.toString())
            true
        } catch (exception: Exception) {
            logger.error(TAG, "Unable to create table $builder: ${exception.localizedMessage}")
            false
        }
    }

    override fun query(tableName: String, size: Int?): List<Map<String, Any?>> {
        val retrievedData = mutableListOf<Map<String, Any?>>()
        try {
            val cursor =
                writableDatabase.query(tableName, null, null, null, null, null, null, size?.toString())
            cursor.use {
                while (it.moveToNext()) {
                    try {
                        retrievedData.add(retrieveColumn(it))
                    } catch (exception: Exception) {
                        logger.error(
                            TAG,
                            "Unable to query an item from $tableName: ${exception.localizedMessage}"
                        )
                    }
                }
            }
        } catch (exception: Exception) {
            logger.error(TAG, "Unable to obtain data from $tableName: ${exception.message}")
        }

        return retrievedData
    }

    override fun dropTable(tableName: String): Boolean {
        // TODO: sanitize tableName
        return try {
            writableDatabase.execSQL("DROP TABLE IF EXISTS $tableName;")
            true
        } catch (exception: Exception) {
            logger.error(
                TAG,
                "Unable to drop table $tableName: ${exception.localizedMessage}"
            )
            false
        }
    }

    override fun insert(
        tableName: String,
        contentValues: List<ContentValues>
    ): Boolean {
        logger.debug(TAG, "Inserting table $tableName with contentValues: ${contentValues.joinToString(",")}")
        if (contentValues.isEmpty()) return true

        // Handle multiple
        writableDatabase.beginTransaction()

        try {
            for (contentValue in contentValues) {
                writableDatabase.insertOrThrow(tableName, null, contentValue)
            }
            writableDatabase.setTransactionSuccessful()
        } catch (exception: Exception) {
            logger.error(
                TAG,
                "Unable to insert items into $tableName: ${exception.localizedMessage}"
            )
            return false
        } finally {
            writableDatabase.endTransaction()
        }
        return true
    }

    override fun delete(tableName: String, keyName: String, ids: List<String>): Int {
        logger.debug(TAG, "Deleting items from table $tableName with ids: ${ids.joinToString(",")}")
        if (ids.isEmpty()) return 0

        val idsString = ids.toTypedArray()
        val whereClause = ids.joinToString(",") { "?" }
        return delete(tableName, "$keyName in ($whereClause)", idsString)
    }

    override fun clear(tableName: String): Boolean {
        return delete(tableName, null, null) != invalidDeleteCount
    }

    private fun retrieveColumn(cursor: Cursor): Map<String, Any?> {
        val column = mutableMapOf<String, Any?>()
        val columnCount = cursor.columnCount
        for (columnIndex in 0 until columnCount) {
            if (!cursor.isNull(columnIndex)) {
                column[cursor.getColumnName(columnIndex)] = retrieveValue(cursor, columnIndex)
            }
        }

        return column
    }

    private fun retrieveValue(cursor: Cursor, columnIndex: Int): Any? {
        return when (cursor.getType(columnIndex)) {
            FIELD_TYPE_NULL -> null
            FIELD_TYPE_FLOAT -> cursor.getDouble(columnIndex)
            FIELD_TYPE_STRING -> cursor.getString(columnIndex)
            FIELD_TYPE_INTEGER -> cursor.getLong(columnIndex)
            FIELD_TYPE_BLOB -> cursor.getBlob(columnIndex)
            else -> null
        }
    }

    private fun delete(tableName: String, whereClause: String?, whereArgs: Array<String>?): Int {
        return try {
            writableDatabase.delete(tableName, whereClause, whereArgs)
        } catch (exception: Exception) {
            logger.error(
                TAG,
                "Unable to delete from $tableName, whereClause: $whereClause, whereArgs: ${whereArgs?.joinToString(
                    ","
                )}: ${exception.localizedMessage}"
            )
            invalidDeleteCount
        }
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.metric

import com.amazonaws.services.chime.sdk.meetings.audiovideo.metric.MetricsObserver

/**
 * [ClientMetricsCollector]'s responsibility is to take raw metrics from both
 * the native audio and native video client and consolidate them as needed
 */
interface ClientMetricsCollector {
    /**
     * Collect the raw audio client metrics and filter observable metrics for eventual
     * callback to the observer
     *
     * @param metrics: Map<Int, Double> - Map of raw audio client metric to value
     */
    fun processAudioClientMetrics(metrics: Map<Int, Double>)

    /**
     * Collect the raw video client metrics and filter observable metrics for eventual
     * callback to the observer
     *
     * @param metrics: Map<Int, Double> - Map of raw video client metric to value
     */
    fun processVideoClientMetrics(metrics: Map<Int, Double>)

    /**
     * Collect the raw content share video client metrics and filter observable metrics for eventual
     * callback to the observer
     *
     * @param metrics: Map<Int, Double> - Map of raw content share video client metric to value
     */
    fun processContentShareVideoClientMetrics(metrics: Map<Int, Double>)

    /**
     * Subscribe to metric events with an [MetricsObserver].
     *
     * @param observer: [MetricsObserver] - The observer to subscribe to metrics with.
     */
    fun subscribeToMetrics(observer: MetricsObserver)

    /**
     * Unsubscribe from metric events with an [MetricsObserver].
     *
     * @param observer: [MetricsObserver] - The observer to unsubscribe from metrics.
     */
    fun unsubscribeFromMetrics(observer: MetricsObserver)
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.metric

import com.amazonaws.services.chime.sdk.meetings.audiovideo.metric.MetricsObserver
import com.amazonaws.services.chime.sdk.meetings.audiovideo.metric.ObservableMetric
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ConcurrentSet
import com.amazonaws.services.chime.sdk.meetings.internal.utils.ObserverUtils
import com.xodee.client.audio.audioclient.AudioClient
import com.xodee.client.video.VideoClient
import java.util.Calendar

/**
 * [DefaultClientMetricsCollector]'s filters and caches incoming raw client metrics
 * and calls the metrics observer with the current values every second
 */
class DefaultClientMetricsCollector :
    ClientMetricsCollector {
    private var metricsObservers = ConcurrentSet.createConcurrentSet<MetricsObserver>()
    private var cachedObservableMetrics = mutableMapOf<ObservableMetric, Double?>()
    private var lastEmittedMetricsTime = Calendar.getInstance().timeInMillis
    private val METRICS_EMISSION_INTERVAL_MS = 1000

    override fun processAudioClientMetrics(metrics: Map<Int, Double>) {
        cachedObservableMetrics[ObservableMetric.audioReceivePacketLossPercent] =
            metrics[AudioClient.AUDIO_CLIENT_METRIC_POST_JB_SPK_1S_PACKETS_LOST_PERCENT]
        cachedObservableMetrics[ObservableMetric.audioSendPacketLossPercent] =
            metrics[AudioClient.AUDIO_SERVER_METRIC_POST_JB_MIC_1S_PACKETS_LOST_PERCENT]
        maybeEmitMetrics()
    }

    override fun processVideoClientMetrics(metrics: Map<Int, Double>) {
        cachedObservableMetrics[ObservableMetric.videoAvailableSendBandwidth] =
            metrics[VideoClient.VIDEO_AVAILABLE_SEND_BANDWIDTH]
        cachedObservableMetrics[ObservableMetric.videoAvailableReceiveBandwidth] =
            metrics[VideoClient.VIDEO_AVAILABLE_RECEIVE_BANDWIDTH]
        cachedObservableMetrics[ObservableMetric.videoSendBitrate] =
            metrics[VideoClient.VIDEO_SEND_BITRATE]
        cachedObservableMetrics[ObservableMetric.videoSendPacketLossPercent] =
            metrics[VideoClient.VIDEO_SEND_PACKET_LOSS_PERCENT]
        cachedObservableMetrics[ObservableMetric.videoSendFps] =
            metrics[VideoClient.VIDEO_SEND_FPS]
        cachedObservableMetrics[ObservableMetric.videoSendRttMs] =
            metrics[VideoClient.VIDEO_SEND_RTT]
        cachedObservableMetrics[ObservableMetric.videoReceiveBitrate] =
            metrics[VideoClient.VIDEO_RECEIVE_BITRATE]
        cachedObservableMetrics[ObservableMetric.videoReceivePacketLossPercent] =
            metrics[VideoClient.VIDEO_RECEIVE_PACKET_LOSS_PERCENT]
        maybeEmitMetrics()
    }

    override fun processContentShareVideoClientMetrics(metrics: Map<Int, Double>) {
        // Currently, content share is send-only
        cachedObservableMetrics[ObservableMetric.contentShareVideoSendBitrate] =
            metrics[VideoClient.VIDEO_SEND_BITRATE]
        cachedObservableMetrics[ObservableMetric.contentShareVideoSendPacketLossPercent] =
            metrics[VideoClient.VIDEO_SEND_PACKET_LOSS_PERCENT]
        cachedObservableMetrics[ObservableMetric.contentShareVideoSendFps] =
            metrics[VideoClient.VIDEO_SEND_FPS]
        cachedObservableMetrics[ObservableMetric.contentShareVideoSendRttMs] =
            metrics[VideoClient.VIDEO_SEND_RTT]
        maybeEmitMetrics()
    }

    private fun maybeEmitMetrics() {
        val now = Calendar.getInstance().timeInMillis
        if (cachedObservableMetrics.isNotEmpty() && now - lastEmittedMetricsTime > METRICS_EMISSION_INTERVAL_MS) {
            lastEmittedMetricsTime = now
            var cachedObservableMetricsWithoutNullValues =
                cachedObservableMetrics.filterValues { it != null } as Map<ObservableMetric, Any>

            ObserverUtils.notifyObserverOnMainThread(metricsObservers) { it.onMetricsReceived(cachedObservableMetricsWithoutNullValues) }
        }
    }

    override fun subscribeToMetrics(observer: MetricsObserver) {
        this.metricsObservers.add(observer)
    }

    override fun unsubscribeFromMetrics(observer: MetricsObserver) {
        this.metricsObservers.remove(observer)
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import android.content.Context
import com.xodee.client.audio.audioclient.AppInfo
import com.xodee.client.video.VideoClient
import java.util.TimeZone

object AppInfoUtil {
    private lateinit var manufacturer: String
    private lateinit var model: String
    private lateinit var osVersion: String
    private lateinit var appName: String
    private lateinit var appCode: String
    private const val clientSource = "amazon-chime-sdk"
    private lateinit var sdkVersion: String
    private lateinit var clientUtcOffset: String

    private fun initializeAppInfo(context: Context) {
        manufacturer = DeviceUtils.deviceManufacturer
        model = DeviceUtils.deviceModel
        osVersion = DeviceUtils.osVersion
        val packageName = context.packageName
        val packageInfo = context.packageManager.getPackageInfo(packageName, 0)
        appName = String.format("Android %s", packageInfo.versionName)
        appCode = packageInfo.versionCode.toString()
        sdkVersion = DeviceUtils.sdkVersion
        val deviceTimezone = TimeZone.getDefault()
        clientUtcOffset = TimezoneUtils.getUtcOffset(deviceTimezone)
    }

    fun initializeVideoClientAppDetailedInfo(context: Context) {
        initializeAppInfo(context)

        VideoClient.AppDetailedInfo.initialize(
            appName,
            appCode,
            model,
            manufacturer,
            osVersion,
            clientSource,
            sdkVersion,
            clientUtcOffset
        )
    }

    fun initializeAudioClientAppInfo(context: Context): AppInfo {
        initializeAppInfo(context)

        return AppInfo(
            appName,
            appCode,
            manufacturer,
            model,
            osVersion,
            clientSource,
            sdkVersion,
            clientUtcOffset
        )
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

interface BackOffRetry {
    fun calculateBackOff(): Long
    fun getRetryCount(): Int
    fun isRetryCountLimitReached(): Boolean
    fun incrementRetryCount()
    fun isRetryableCode(responseCode: Int): Boolean
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import java.util.Collections
import java.util.concurrent.ConcurrentHashMap

class ConcurrentSet {
    companion object {
        fun <T> createConcurrentSet(): MutableSet<T> {
            return Collections.newSetFromMap(ConcurrentHashMap<T, Boolean>())
        }
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import kotlin.math.pow

class DefaultBackOffRetry(
    private val maxRetry: Int = 0,
    private val backOff: Long = 0,
    private val retryableStatusCodes: Set<Int> = HashSet()
) : BackOffRetry {
    private val multiplier = 1
    private var currentRetry = 0

    override fun calculateBackOff(): Long {
        return (backOff * multiplier.toDouble().pow(currentRetry.toDouble())).toLong()
    }

    override fun getRetryCount(): Int {
        return currentRetry
    }

    override fun isRetryCountLimitReached(): Boolean {
        return maxRetry > currentRetry
    }

    override fun incrementRetryCount() {
        currentRetry++
    }

    override fun isRetryableCode(responseCode: Int): Boolean {
        return retryableStatusCodes.isEmpty() || retryableStatusCodes.contains(responseCode)
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import com.amazonaws.services.chime.sdk.meetings.utils.Versioning
import com.biba.android.MediaClient.BuildConfig

/**
 * [DeviceUtils] stores general device/SDK information
 */
class DeviceUtils {
    companion object {
        val sdkName: String by lazy {
            "amazon-chime-sdk-android"
        }
        val sdkVersion: String by lazy {
            Versioning.sdkVersion()
        }

        val deviceModel: String by lazy {
            android.os.Build.MODEL
        }

        val deviceManufacturer: String by lazy {
            android.os.Build.MANUFACTURER
        }

        val deviceName: String by lazy {
            "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}"
        }

        val osName: String by lazy {
            "Android"
        }
        val osVersion: String by lazy {
            android.os.Build.VERSION.RELEASE
        }

        val mediaSDKVersion: String by lazy {
            BuildConfig.VERSION_NAME
        }
    }
}


package com.amazonaws.services.chime.sdk.meetings.internal.utils

import android.annotation.SuppressLint
import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger
import java.lang.reflect.Method
import java.net.InetAddress

object DNSServerUtils {
    private val TAG = "DNSServerUtils"

    fun getAvailableDnsServers(context: Context, logger: Logger): Array<String> {
        val dnsHosts: MutableList<String> = ArrayList()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            try {
                val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
                val linkProperties = connectivityManager.getLinkProperties(connectivityManager.activeNetwork)
                if (linkProperties != null) {
                    val addresses: List<InetAddress> = linkProperties.dnsServers
                    for (i in addresses.indices) {
                        if (addresses[i].hostAddress.isNotEmpty()) {
                            dnsHosts.add(addresses[i].hostAddress)
                        }
                    }
                    logger.info(TAG, "Get " + dnsHosts.size + " DNS addresses.")
                }
            } catch (e: Exception) {
                logger.error(TAG, "Failed to get active DNS address.")
            }
        } else {
            try {
                @SuppressLint("PrivateApi") val SystemProperties = Class.forName("android.os.SystemProperties")
                val method: Method = SystemProperties.getMethod("get", String::class.java)
                for (name in arrayOf("net.dns1", "net.dns2")) {
                    val value = method.invoke(null, name) as String
                    if (value.isNotEmpty()) {
                        dnsHosts.add(value)
                    }
                }
                logger.info(TAG, "Get " + dnsHosts.size + " DNS addresses,")
            } catch (e: Exception) {
                logger.error(TAG, "Failed to get active DNS address.")
            }
        }
        return dnsHosts.toTypedArray()
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import com.amazonaws.services.chime.sdk.meetings.analytics.EventAttributeName
import com.amazonaws.services.chime.sdk.meetings.analytics.EventAttributes
import com.amazonaws.services.chime.sdk.meetings.analytics.toStringKeyMap
import com.amazonaws.services.chime.sdk.meetings.ingestion.IngestionConfiguration
import com.amazonaws.services.chime.sdk.meetings.ingestion.IngestionMetadata
import com.amazonaws.services.chime.sdk.meetings.session.MeetingSessionConfiguration

object EventAttributesUtils {
    fun getIngestionMetadata(ingestionConfiguration: IngestionConfiguration): IngestionMetadata {
        val metadata = ingestionConfiguration.clientConfiguration.metadataAttributes.toMutableMap()
        metadata.putAll(getCommonAttributes().toStringKeyMap())

        return metadata
    }

    fun getCommonAttributes(meetingSessionConfiguration: MeetingSessionConfiguration): EventAttributes {
        val attributes = getCommonAttributes()

        attributes.putAll(mapOf(
            EventAttributeName.meetingId to meetingSessionConfiguration.meetingId,
            EventAttributeName.attendeeId to
                    meetingSessionConfiguration.credentials.attendeeId,
            EventAttributeName.externalUserId to
                    meetingSessionConfiguration.credentials.externalUserId
        ))

        meetingSessionConfiguration.externalMeetingId?.let {
            attributes[EventAttributeName.externalMeetingId] = it
        }

        return attributes
    }

    fun getCommonAttributes(): EventAttributes {
        val attributes = mutableMapOf(
            EventAttributeName.deviceName to DeviceUtils.deviceName,
            EventAttributeName.deviceManufacturer to DeviceUtils.deviceManufacturer,
            EventAttributeName.deviceModel to DeviceUtils.deviceModel,
            EventAttributeName.mediaSdkVersion to DeviceUtils.mediaSDKVersion,
            EventAttributeName.osName to DeviceUtils.osName,
            EventAttributeName.osVersion to DeviceUtils.osVersion,
            EventAttributeName.sdkName to DeviceUtils.sdkName,
            EventAttributeName.sdkVersion to DeviceUtils.sdkVersion
        )
        return attributes as EventAttributes
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

data class HttpException(val errorCode: Int? = null, val errorReason: String? = "") :
    Exception()


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

data class HttpResponse(val data: String? = null, val httpException: HttpException? = null)


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext

object HttpUtils {
    private val TAG = "HttpUtils"
    private val SYSPROP_USER_AGENT = "http.agent"
    private val USER_AGENT_HEADER = "User-Agent"

    /**
     * Post request. In order to get value, one has to do
     * dispatcher.launch {
     *      result = post(...)
     *      use result to do sth
     * }
     *
     * @param url: [URL] - URL of the server
     * @param body: [String] - json String body
     * @param backOffRetry: [BackOffRetry] - Retry policy
     * @param logger - [Logger] - logger to log the data
     * @param headers - [Map<String, String>] - headers to add the http call
     * @return HttpResponse containing data and error
     */
    suspend fun post(
        url: URL,
        body: String,
        backOffRetry: BackOffRetry = DefaultBackOffRetry(),
        logger: Logger? = null,
        headers: Map<String, String> = emptyMap()
    ): HttpResponse {
        var response: HttpResponse
        do {
            response = makePostRequest(url, body, logger, headers)
            if (response.httpException == null || !backOffRetry.isRetryableCode(
                    response.httpException?.errorCode ?: 0
                )
            ) {
                return response
            }
            backOffRetry.incrementRetryCount()
            val waitTime = backOffRetry.calculateBackOff()
            if (waitTime > 0) {
                delay(waitTime)
            }
        } while (backOffRetry.isRetryCountLimitReached())
        return response
    }

    private suspend fun makePostRequest(url: URL, body: String, logger: Logger?, headers: Map<String, String>): HttpResponse {
        return withContext(Dispatchers.IO) {
            try {
                val response = StringBuffer()
                with(url.openConnection() as HttpURLConnection) {
                    requestMethod = "POST"
                    doInput = true
                    doOutput = true
                    for (header in headers) {
                        setRequestProperty(header.key, header.value)
                    }
                    setRequestProperty("Content-Type", "application/json")
                    val userAgent = System.getProperty(SYSPROP_USER_AGENT)
                    setRequestProperty(USER_AGENT_HEADER, userAgent)
                    outputStream.use {
                        val input = body.toByteArray()
                        it.write(input, 0, input.size)
                    }

                    val inStream = if (responseCode in 200..299) inputStream else errorStream
                    BufferedReader(InputStreamReader(inStream)).use {
                        var inputLine = it.readLine()
                        while (inputLine != null) {
                            response.append(inputLine)
                            inputLine = it.readLine()
                        }
                        it.close()
                    }

                    if (responseCode in 200..299) {
                        return@withContext HttpResponse(response.toString(), null)
                    }

                    return@withContext HttpResponse(
                        response.toString(),
                        HttpException(errorCode = responseCode)
                    )
                }
            } catch (exception: Exception) {
                logger?.error(TAG, "Unable to send request $exception")
                return@withContext HttpResponse(null, HttpException(null, exception.toString()))
            }
        }
    }
}


package com.amazonaws.services.chime.sdk.meetings.internal.utils

import com.google.gson.Gson

class JsonUtils {
    companion object {
        private val gson = Gson()

        fun marshal(data: Any): String {
            return gson.toJson(data)
        }
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/**
 * [ObserverUtils] are util functions that can be used with observers.
 */
class ObserverUtils {
    companion object {
        private val uiScope = CoroutineScope(Dispatchers.Main)
        /**
         * Run observer methods on main thread
         *
         * @param observers: Set<T> - Set of observers
         * @param observerFunction: (observer: T) -> Unit - function to be executed with observer
         */
        fun <T> notifyObserverOnMainThread(observers: Set<T>, observerFunction: (observer: T) -> Unit) {
            uiScope.launch {
                for (observer in observers) {
                    observerFunction(observer)
                }
            }
        }
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import java.util.GregorianCalendar
import java.util.TimeZone

class TimezoneUtils {
    companion object {
        private const val UTC_OFFSET_FORMAT = "%02d:%02d"

        /**
         * Return utc offset from timezone in +-hh:mm format.
         * E.g Asia/Calcutta timezone is returned as +05:30 UTC offset
         */
        fun getUtcOffset(timezone: TimeZone): String {
            val offsetMillis = timezone.getOffset(GregorianCalendar.getInstance(timezone).timeInMillis)
            val offsetHours = Math.abs((offsetMillis / 60000) / 60)
            val offsetMinutes = Math.abs((offsetMillis / 60000) % 60)
            var offset: String = String.format(UTC_OFFSET_FORMAT, offsetHours, offsetMinutes)
            offset = (if (offsetMillis >= 0) "+" else "-") + offset
            return offset
        }
    }
}


/*
 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 * SPDX-License-Identifier: Apache-2.0
 */

package com.amazonaws.services.chime.sdk.meetings.internal.utils

import com.amazonaws.services.chime.sdk.meetings.internal.video.TURNCredentials
import com.amazonaws.services.chime.sdk.meetings.internal.video.TURNRequestParams
import com.amazonaws.services.chime.sdk.meetings.utils.DefaultModality
import com.amazonaws.services.chime.sdk.meetings.utils.logger.Logger
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject

object TURNRequestUtils {
    private val TOKEN_HEADER = "X-Chime-Auth-Token"
    private val SYSPROP_USER_AGENT = "http.agent"
    private val USER_AGENT_HEADER = "User-Agent"
    private val CONTENT_TYPE_HEADER = "Content-Type"
    private val CONTENT_TYPE = "application/json"
    private val MEETING_ID_KEY = "meetingId"
    private val TOKEN_KEY = "_aws_wt_session"

    private val TAG = "TURNRequestUtils"

    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO

    suspend fun doTurnRequest(turnRequestParams: TURNRequestParams, logger: Logger): TURNCredentials? {
        return withContext(ioDispatcher) {
            try {
                val response = StringBuffer()
                logger.info(TAG, "Making TURN Request")
                with(URL(turnRequestParams.turnControlUrl).openConnection() as HttpURLConnection) {
                    requestMethod = "POST"
                    doInput = true
                    doOutput = true
                    addRequestProperty(TOKEN_HEADER, "$TOKEN_KEY=${DefaultModality(turnRequestParams.joinToken).base()}")
                    setRequestProperty(CONTENT_TYPE_HEADER, CONTENT_TYPE)
                    val user_agent = System.getProperty(SYSPROP_USER_AGENT)
                    logger.info(TAG, "User Agent while doing TURN request is $user_agent")
                    setRequestProperty(USER_AGENT_HEADER, user_agent)
                    val out = BufferedWriter(OutputStreamWriter(outputStream))
                    out.write(
                        JSONObject().put(
                            MEETING_ID_KEY,
                            turnRequestParams.meetingId
                        ).toString()
                    )
                    out.flush()
                    out.close()
                    BufferedReader(InputStreamReader(inputStream)).use {
                        var inputLine = it.readLine()
                        while (inputLine != null) {
                            response.append(inputLine)
                            inputLine = it.readLine()
                        }
                        it.close()
                    }
                    if (responseCode == 200) {
                        logger.info(TAG, "TURN Request Success")
                        var responseObject = JSONObject(response.toString())
                        val jsonArray =
                            responseObject.getJSONArray(TURNCredentials.TURN_CREDENTIALS_RESULT_URIS)
                        val uris = arrayOfNulls<String>(jsonArray.length())
                        for (i in 0 until jsonArray.length()) {
                            uris[i] = jsonArray.getString(i)
                        }
                        TURNCredentials(
                            responseObject.getString(TURNCredentials.TURN_CREDENTIALS_RESULT_USERNAME),
                            responseObject.getString(TURNCredentials.TURN_CREDENTIALS_RESULT_PASSWORD),
                            responseObject.getString(TURNCredentials.TURN_CREDENTIALS_RESULT_TTL),
                            uris
                        )
                    } else {
                        logger.error(
                            TAG,
                            "TURN Request got error with Response code: $responseCode"
                        )
                        null
                    }
                }
            } catch (exception: Exception) {
                logger.error(TAG, "Exception while doing TURN Request: $exception")
                null
            }
        }
    }
}