package ch.threema.app.voip.groupcall

import android.os.SystemClock
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import ch.threema.app.voip.groupcall.sfu.CallId
import ch.threema.app.voip.groupcall.sfu.GroupCallState
import ch.threema.base.crypto.NaCl
import ch.threema.base.utils.getThreemaLogger
import ch.threema.common.generateRandomBytes
import ch.threema.common.secureRandom
import ch.threema.common.toHexString
import ch.threema.data.datatypes.LocalGroupId
import ch.threema.storage.models.group.GroupCallModel
import java.time.Instant

private val logger = getThreemaLogger("GroupCallDescription")

@AnyThread
data class GroupCallDescription(
    val protocolVersion: UInt,
    val groupId: LocalGroupId,
    val sfuBaseUrl: String,
    val callId: CallId,
    val gck: ByteArray,
    var startedAt: ULong,
    val processedAt: ULong = startedAt,
    var maxParticipants: UInt? = null,
) {
    private val gchk: ByteArray by lazy { CryptoCallUtils.gcBlake2b256(key = gck, salt = CryptoCallUtils.SALT_GCHK) }
    private val gcsk: ByteArray by lazy { CryptoCallUtils.gcBlake2b256(key = gck, salt = CryptoCallUtils.SALT_GCSK) }
    val gckh: ByteArray by lazy { CryptoCallUtils.gcBlake2b256(key = gck, salt = CryptoCallUtils.SALT_GCKH) }

    var callState: GroupCallState? = null

    @WorkerThread
    fun setEncryptedCallState(encryptedCallState: ByteArray?) {
        callState = encryptedCallState?.let {
            if (it.isNotEmpty()) {
                val decryptedBytes = decryptByGcsk(it)
                if (decryptedBytes == null) {
                    logger.warn("Could not decrypt call state")
                    null
                } else {
                    GroupCallState.fromProtobufBytes(decryptedBytes)
                }
            } else {
                null
            }
        }
    }

    @AnyThread
    fun getStartedAtDate(): Instant = Instant.ofEpochMilli(startedAt.toLong())

    /**
     * Get the running time of this call in milliseconds.
     * The running time is relative to [SystemClock.elapsedRealtime]
     *
     * If [startedAt] is dated in the future, null will be returned.
     *
     * @return The time in milliseconds since this call has been started
     */
    fun getRunningSinceStarted(): Long? {
        val currentTime: Long = System.currentTimeMillis()
        return if (currentTime >= startedAt.toLong()) {
            val durationMs: Long = currentTime - startedAt.toLong()
            SystemClock.elapsedRealtime() - durationMs
        } else {
            null
        }
    }

    /**
     * Get the time of this call in milliseconds since the group message has been processed.
     * The time is relative to [SystemClock.elapsedRealtime].
     *
     * If [processedAt] is dated in the future, [SystemClock.elapsedRealtime] will be returned.
     *
     * If the device's time is potentially wrong, this method can be used instead of
     * [getRunningSinceStarted] as it is relative to this device's time.
     *
     * @return The time in milliseconds since this call has been processed
     */
    fun getRunningSinceProcessed(): Long {
        val currentTime: Long = System.currentTimeMillis()
        val durationMs: Long = (currentTime - processedAt.toLong()).coerceAtLeast(0L)
        return SystemClock.elapsedRealtime() - durationMs
    }

    @AnyThread
    fun getGroupIdInt(): Int = groupId.id

    @AnyThread
    fun toGroupCallModel(): GroupCallModel {
        return GroupCallModel(
            protocolVersion.toInt(),
            callId.bytes.toHexString(),
            groupId.id,
            sfuBaseUrl,
            gck.toHexString(),
            startedAt.toLong(),
            processedAt.toLong(),
        )
    }

    /**
     * Encrypts data symmetrically by using GCHK as key and random nonce.
     * The nonce will be prefixed to the encrypted data.
     */
    @WorkerThread
    fun encryptWithGchk(data: ByteArray): ByteArray {
        return encrypt(data, gchk)
    }

    /**
     * Decrypts data which is symmetrically encrypted by GCHK and has a prefixed nonce.
     */
    @WorkerThread
    fun decryptByGchk(data: ByteArray): ByteArray? {
        return decrypt(data, gchk)
    }

    /**
     * Encrypts data symmetrically by using GCSK as key and random nonce.
     * The nonce will be prefixed to the encrypted data.
     */
    @WorkerThread
    fun encryptWithGcsk(data: ByteArray): ByteArray {
        return encrypt(data, gcsk)
    }

    /**
     * Decrypts data which is symmetrically encrypted by GCSK and has a prefixed nonce.
     */
    @WorkerThread
    private fun decryptByGcsk(data: ByteArray): ByteArray? {
        return decrypt(data, gcsk)
    }

    @WorkerThread
    private fun encrypt(data: ByteArray, key: ByteArray): ByteArray {
        val nonce = secureRandom().generateRandomBytes(NaCl.NONCE_BYTES)
        val encrypted = NaCl.symmetricEncryptData(
            data = data,
            key = key,
            nonce = nonce,
        )
        return nonce + encrypted
    }

    @WorkerThread
    private fun decrypt(encryptedData: ByteArray, key: ByteArray): ByteArray? {
        val nonce = encryptedData.copyOfRange(0, NaCl.NONCE_BYTES)
        val data = encryptedData.copyOfRange(NaCl.NONCE_BYTES, encryptedData.size)
        return runCatching {
            NaCl.symmetricDecryptData(
                data = data,
                key = key,
                nonce = nonce,
            )
        }.getOrElse { throwable ->
            logger.error("Failed to decrypt data", throwable)
            null
        }
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is GroupCallDescription) return false

        if (protocolVersion != other.protocolVersion) return false
        if (groupId != other.groupId) return false
        if (sfuBaseUrl != other.sfuBaseUrl) return false
        if (callId != other.callId) return false
        if (!gck.contentEquals(other.gck)) return false

        return true
    }

    override fun hashCode(): Int {
        var result = protocolVersion.hashCode()
        result = 31 * result + groupId.hashCode()
        result = 31 * result + sfuBaseUrl.hashCode()
        result = 31 * result + callId.hashCode()
        result = 31 * result + gck.contentHashCode()
        return result
    }

    override fun toString() =
        "GroupCallDescription(protocolVersion=$protocolVersion, groupId=$groupId, sfuBaseUrl='$sfuBaseUrl', startedAt=$startedAt, " +
            "maxParticipants=$maxParticipants, callId=$callId, callState=$callState)"
}

fun GroupCallModel.toGroupCallDescription(): GroupCallDescription {
    return GroupCallDescription(
        protocolVersion = getProtocolVersionUnsigned(),
        groupId = LocalGroupId(groupId),
        sfuBaseUrl = sfuBaseUrl,
        callId = CallId(callId.hexToByteArray()),
        gck = gck.hexToByteArray(),
        startedAt = getStartedAtUnsigned(),
        processedAt = getProcessedAtUnsigned(),
        maxParticipants = null,
    )
}
