/* Copyright 2019 Tusky Contributors
 *
 * This file is a part of Tusky.
 *
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 3 of the
 * License, or (at your option) any later version.
 *
 * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
 * Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with Tusky; if not,
 * see <http://www.gnu.org/licenses>. */

package com.keylesspalace.tusky.components.compose

import android.content.ContentResolver
import android.content.Context
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE
import android.net.Uri
import android.os.Environment
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.asRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize
import com.keylesspalace.tusky.util.getServerErrorMessage
import com.keylesspalace.tusky.util.randomAlphanumericString
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.shareIn
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okio.buffer
import okio.sink
import okio.source
import retrofit2.HttpException

sealed interface FinalUploadEvent

sealed interface UploadEvent {
    data class ProgressEvent(val percentage: Int) : UploadEvent
    data class FinishedEvent(
        val mediaId: String,
        val processed: Boolean
    ) : UploadEvent, FinalUploadEvent
    data class ErrorEvent(val error: Throwable) : UploadEvent, FinalUploadEvent
}

data class UploadData(
    val flow: Flow<UploadEvent>,
    val scope: CoroutineScope
)

fun createNewImageFile(context: Context, suffix: String = ".jpg"): File {
    // Create an image file name
    val randomId = randomAlphanumericString(12)
    val imageFileName = "Tusky_${randomId}_"
    val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
    return File.createTempFile(imageFileName, suffix, storageDir)
}

data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)

class FileSizeException(val allowedSizeInBytes: Int) : Exception()
class MediaTypeException : Exception()
class CouldNotOpenFileException : Exception()
class UploadServerError(val errorMessage: String) : Exception()

@Singleton
class MediaUploader @Inject constructor(
    private val context: Context,
    private val mediaUploadApi: MediaUploadApi
) {

    private val uploads = mutableMapOf<Int, UploadData>()

    private var mostRecentId: Int = 0

    fun getNewLocalMediaId(): Int {
        return mostRecentId++
    }

    suspend fun getMediaUploadState(localId: Int): FinalUploadEvent {
        return uploads[localId]?.flow
            ?.filterIsInstance<FinalUploadEvent>()
            ?.first()
            ?: UploadEvent.ErrorEvent(IllegalStateException("media upload with id $localId not found"))
    }

    /**
     * Uploads media.
     * @param media the media to upload
     * @param instanceInfo info about the current media to make sure the media gets resized correctly
     * @return A Flow emitting upload events.
     * The Flow is hot, in order to cancel upload or clear resources call [cancelUploadScope].
     */
    @OptIn(ExperimentalCoroutinesApi::class)
    fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow<UploadEvent> {
        val uploadScope = CoroutineScope(Dispatchers.IO)
        val uploadFlow = flow {
            if (shouldResizeMedia(media, instanceInfo)) {
                emit(downsize(media, instanceInfo))
            } else {
                emit(media)
            }
        }
            .flatMapLatest { upload(it) }
            .catch { exception ->
                emit(UploadEvent.ErrorEvent(exception))
            }
            .shareIn(uploadScope, SharingStarted.Lazily, 1)

        uploads[media.localId] = UploadData(uploadFlow, uploadScope)
        return uploadFlow
    }

    /**
     * Cancels the CoroutineScope of a media upload.
     * Call this when to abort the upload or to clean up resources after upload info is no longer needed
     */
    fun cancelUploadScope(vararg localMediaIds: Int) {
        localMediaIds.forEach { localId ->
            uploads.remove(localId)?.scope?.cancel()
        }
    }

    fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): PreparedMedia {
        var mediaSize = MEDIA_SIZE_UNKNOWN
        var uri = inUri
        val mimeType: String?

        try {
            when (inUri.scheme) {
                ContentResolver.SCHEME_CONTENT -> {
                    mimeType = contentResolver.getType(uri)

                    val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")

                    contentResolver.openInputStream(inUri)?.source().use { input ->
                        if (input == null) {
                            Log.w(TAG, "Media input is null")
                            uri = inUri
                            return@use
                        }
                        val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
                        file.absoluteFile.sink().buffer().use { out ->
                            out.writeAll(input)
                        }
                        uri = FileProvider.getUriForFile(
                            context,
                            BuildConfig.APPLICATION_ID + ".fileprovider",
                            file
                        )
                        mediaSize = getMediaSize(contentResolver, uri)
                    }
                }
                ContentResolver.SCHEME_FILE -> {
                    val path = uri.path
                    if (path == null) {
                        Log.w(TAG, "empty uri path $uri")
                        throw CouldNotOpenFileException()
                    }
                    val inputFile = File(path)
                    val suffix = inputFile.name.substringAfterLast('.', "tmp")
                    mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
                    val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)

                    inputFile.source().use { input ->
                        file.absoluteFile.sink().buffer().use { out ->
                            out.writeAll(input)
                        }
                    }
                    uri = FileProvider.getUriForFile(
                        context,
                        BuildConfig.APPLICATION_ID + ".fileprovider",
                        file
                    )
                    mediaSize = getMediaSize(contentResolver, uri)
                }
                else -> {
                    Log.w(TAG, "Unknown uri scheme $uri")
                    throw CouldNotOpenFileException()
                }
            }
        } catch (e: IOException) {
            Log.w(TAG, e)
            throw CouldNotOpenFileException()
        }
        if (mediaSize == MEDIA_SIZE_UNKNOWN) {
            Log.w(TAG, "Could not determine file size of upload")
            throw MediaTypeException()
        }

        if (mimeType != null) {
            return when (mimeType.substring(0, mimeType.indexOf('/'))) {
                "video" -> {
                    if (mediaSize > instanceInfo.videoSizeLimit) {
                        throw FileSizeException(instanceInfo.videoSizeLimit)
                    }
                    PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
                }
                "image" -> {
                    PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
                }
                "audio" -> {
                    if (mediaSize > instanceInfo.videoSizeLimit) {
                        throw FileSizeException(instanceInfo.videoSizeLimit)
                    }
                    PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
                }
                else -> {
                    throw MediaTypeException()
                }
            }
        } else {
            Log.w(TAG, "Could not determine mime type of upload")
            throw MediaTypeException()
        }
    }

    private val contentResolver = context.contentResolver

    private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
        return callbackFlow {
            var mimeType = contentResolver.getType(media.uri)

            // Android's MIME type suggestions from file extensions is broken for at least
            // .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details.
            // Sniff the content of the file to determine the actual type.
            if (mimeType != null && (
                    mimeType.startsWith("audio/", ignoreCase = true) ||
                        mimeType.startsWith("video/", ignoreCase = true)
                    )
            ) {
                val retriever = MediaMetadataRetriever()
                retriever.setDataSource(context, media.uri)
                mimeType = retriever.extractMetadata(METADATA_KEY_MIMETYPE)
            }
            val map = MimeTypeMap.getSingleton()
            val fileExtension = map.getExtensionFromMimeType(mimeType)
            val filename = "%s_%d_%s.%s".format(
                context.getString(R.string.app_name),
                System.currentTimeMillis(),
                randomAlphanumericString(10),
                fileExtension
            )

            if (mimeType == null) mimeType = "multipart/form-data"

            var lastProgress = -1
            val fileBody = media.uri.asRequestBody(
                contentResolver,
                requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
                media.mediaSize
            ) { percentage ->
                if (percentage != lastProgress) {
                    trySend(UploadEvent.ProgressEvent(percentage))
                }
                lastProgress = percentage
            }

            val body = MultipartBody.Part.createFormData("file", filename, fileBody)

            val description = if (media.description != null) {
                MultipartBody.Part.createFormData("description", media.description)
            } else {
                null
            }

            val focus = if (media.focus != null) {
                MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}")
            } else {
                null
            }

            val uploadResponse = mediaUploadApi.uploadMedia(body, description, focus)
            val responseBody = uploadResponse.body()
            if (uploadResponse.isSuccessful && responseBody != null) {
                send(UploadEvent.FinishedEvent(responseBody.id, uploadResponse.code() == 200))
            } else {
                val error = HttpException(uploadResponse)
                val errorMessage = error.getServerErrorMessage()
                if (errorMessage == null) {
                    throw error
                } else {
                    throw UploadServerError(errorMessage)
                }
            }

            awaitClose()
        }
    }

    private fun downsize(media: QueuedMedia, instanceInfo: InstanceInfo): QueuedMedia {
        val file = createNewImageFile(context)
        downsizeImage(media.uri, instanceInfo.imageSizeLimit, contentResolver, file)
        return media.copy(uri = file.toUri(), mediaSize = file.length())
    }

    private fun shouldResizeMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Boolean {
        return media.type == QueuedMedia.Type.IMAGE &&
            (media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit)
    }

    private companion object {
        private const val TAG = "MediaUploader"
    }
}
