/*
 * Copyright (c) 2023 New Vector Ltd
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package io.element.android.features.rageshake.impl.reporter

import android.content.Context
import android.os.Build
import android.text.format.DateUtils.DAY_IN_MILLIS
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.rageshake.api.crash.CrashDataStore
import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.api.reporter.ReportType
import io.element.android.features.rageshake.api.screenshot.ScreenshotHolder
import io.element.android.features.rageshake.impl.R
import io.element.android.libraries.androidutils.file.compressFile
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.toOnOff
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.Response
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.util.Locale
import javax.inject.Inject
import javax.inject.Provider

/**
 * BugReporter creates and sends the bug reports.
 */
@ContributesBinding(AppScope::class)
class DefaultBugReporter @Inject constructor(
    @ApplicationContext private val context: Context,
    private val screenshotHolder: ScreenshotHolder,
    private val crashDataStore: CrashDataStore,
    private val coroutineScope: CoroutineScope,
    private val systemClock: SystemClock,
    private val coroutineDispatchers: CoroutineDispatchers,
    private val okHttpClient: Provider<OkHttpClient>,
    private val userAgentProvider: UserAgentProvider,
    private val sessionStore: SessionStore,
    private val buildMeta: BuildMeta,
    /*
    private val versionProvider: VersionProvider,
    private val vectorPreferences: VectorPreferences,
    private val vectorFileLogger: VectorFileLogger,
    private val systemLocaleProvider: SystemLocaleProvider,
    private val matrix: Matrix,
    private val processInfo: ProcessInfo,
    private val sdkIntProvider: BuildVersionSdkIntProvider,
    private val vectorLocale: VectorLocaleProvider,
     */
) : BugReporter {
    var inMultiWindowMode = false

    companion object {
        // filenames
        private const val LOG_CAT_ERROR_FILENAME = "logcatError.log"
        private const val LOG_CAT_FILENAME = "logcat.log"
        private const val LOG_DIRECTORY_NAME = "logs"
        // private const val KEY_REQUESTS_FILENAME = "keyRequests.log"

        private const val BUFFER_SIZE = 1024 * 1024 * 50
    }

    // the pending bug report call
    private var bugReportCall: Call? = null

    // boolean to cancel the bug report
    private val isCancelled = false

    /*
    val adapter = MatrixJsonParser.getMoshi()
            .adapter<JsonDict>(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java))
     */

    private val logcatCommandError = arrayOf(
        "logcat", // /< Run 'logcat' command
        "-d", // /< Dump the log rather than continue outputting it
        "-v", // formatting
        "threadtime", // include timestamps
        "AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging
            "libcommunicator:V " + // /< All libcommunicator logging
            "DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc)
            "*:S" // /< Everything else silent, so don't pick it..
    )

    private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*")

    /**
     * Send a bug report.
     *
     * @param reportType The report type (bug, suggestion, feedback)
     * @param withDevicesLogs true to include the device log
     * @param withCrashLogs true to include the crash logs
     * @param withKeyRequestHistory true to include the crash logs
     * @param withScreenshot true to include the screenshot
     * @param theBugDescription the bug description
     * @param serverVersion version of the server
     * @param canContact true if the user opt in to be contacted directly
     * @param customFields fields which will be sent with the report
     * @param listener the listener
     */
    override suspend fun sendBugReport(
        reportType: ReportType,
        withDevicesLogs: Boolean,
        withCrashLogs: Boolean,
        withKeyRequestHistory: Boolean,
        withScreenshot: Boolean,
        theBugDescription: String,
        serverVersion: String,
        canContact: Boolean,
        customFields: Map<String, String>?,
        listener: BugReporterListener?
    ) {
        // enumerate files to delete
        val bugReportFiles: MutableList<File> = ArrayList()

        try {

            var serverError: String? = null
            var reportURL: String? = null
            withContext(coroutineDispatchers.io) {
                var bugDescription = theBugDescription
                val crashCallStack = crashDataStore.crashInfo().first()

                if (crashCallStack.isNotEmpty() && withCrashLogs) {
                    bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n"
                    bugDescription += crashCallStack
                }

                val gzippedFiles = ArrayList<File>()

                if (withDevicesLogs) {
                    val files = getLogFiles()
                    files.mapNotNullTo(gzippedFiles) { f ->
                        when {
                            isCancelled -> null
                            f.extension == "gz" -> f
                            else -> compressFile(f)
                        }
                    }
                    files.deleteAllExceptMostRecent()
                }

                if (!isCancelled && (withCrashLogs || withDevicesLogs)) {
                    val gzippedLogcat = saveLogCat(false)

                    if (null != gzippedLogcat) {
                        if (gzippedFiles.size == 0) {
                            gzippedFiles.add(gzippedLogcat)
                        } else {
                            gzippedFiles.add(0, gzippedLogcat)
                        }
                    }
                }

                /*
            activeSessionHolder.getSafeActiveSession()
                    ?.takeIf { !mIsCancelled && withKeyRequestHistory }
                    ?.cryptoService()
                    ?.getGossipingEvents()
                    ?.let { GossipingEventsSerializer().serialize(it) }
                    ?.toByteArray()
                    ?.let { rawByteArray ->
                        File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME)
                                .also {
                                    it.outputStream()
                                            .use { os -> os.write(rawByteArray) }
                                }
                    }
                    ?.let { compressFile(it) }
                    ?.let { gzippedFiles.add(it) }
             */

                val sessionData = sessionStore.getLatestSession()
                val deviceId = sessionData?.deviceId ?: "undefined"
                val userId = sessionData?.userId ?: "undefined"
                var olmVersion = "undefined"

                if (!isCancelled) {
                    val text = when (reportType) {
                        ReportType.BUG_REPORT -> bugDescription
                        ReportType.SUGGESTION -> "[Suggestion] $bugDescription"
                        ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription"
                        ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription"
                        ReportType.AUTO_UISI_SENDER,
                        ReportType.AUTO_UISI -> bugDescription
                    }

                    // build the multi part request
                    val builder = BugReporterMultipartBody.Builder()
                        .addFormDataPart("text", text)
                        .addFormDataPart("app", rageShakeAppNameForReport(reportType))
                        .addFormDataPart("user_agent", userAgentProvider.provide())
                        .addFormDataPart("user_id", userId)
                        .addFormDataPart("can_contact", canContact.toString())
                        .addFormDataPart("device_id", deviceId)
                        // .addFormDataPart("version", versionProvider.getVersion(longFormat = true))
                        // .addFormDataPart("branch_name", buildMeta.gitBranchName)
                        // .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion())
                        .addFormDataPart("olm_version", olmVersion)
                        .addFormDataPart("device", Build.MODEL.trim())
                        // .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff())
                        .addFormDataPart("multi_window", inMultiWindowMode.toOnOff())
                        // .addFormDataPart(
                        //        "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " +
                        //        Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME
                        // )
                        .addFormDataPart("locale", Locale.getDefault().toString())
                        // .addFormDataPart("app_language", vectorLocale.applicationLocale.toString())
                        // .addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString())
                        // .addFormDataPart("theme", ThemeUtils.getApplicationTheme(context))
                        .addFormDataPart("server_version", serverVersion)
                        .apply {
                            customFields?.forEach { (name, value) ->
                                addFormDataPart(name, value)
                            }
                        }

                    // add the gzipped files, don't cancel the whole upload if only some file failed to upload
                    var uploadedSomeLogs = false
                    for (file in gzippedFiles) {
                        try {
                            builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()))
                            uploadedSomeLogs = true
                        } catch (e: CancellationException) {
                            throw e
                        } catch (e: Exception) {
                            Timber.e(e, "## sendBugReport() : fail to attach file ${file.name}")
                        }
                    }

                    bugReportFiles.addAll(gzippedFiles)

                    if (gzippedFiles.isNotEmpty() && !uploadedSomeLogs) {
                        serverError = "Couldn't upload any logs, please retry."
                        return@withContext
                    }

                    if (withScreenshot) {
                        screenshotHolder.getFileUri()
                            ?.toUri()
                            ?.toFile()
                            ?.let { screenshotFile ->
                                try {
                                    builder.addFormDataPart(
                                        "file",
                                        screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())
                                    )
                                } catch (e: Exception) {
                                    Timber.e(e, "## sendBugReport() : fail to write screenshot")
                                }
                            }
                    }

                    // add some github labels
                    builder.addFormDataPart("label", buildMeta.versionName)
                    // builder.addFormDataPart("label", buildMeta.flavorDescription)
                    // builder.addFormDataPart("label", buildMeta.gitBranchName)

                    // Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release".
                    // builder.addFormDataPart("label", BuildConfig.BUILD_TYPE)

                    when (reportType) {
                        ReportType.BUG_REPORT -> {
                            /* nop */
                        }
                        ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]")
                        ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback")
                        ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback")
                        ReportType.AUTO_UISI -> {
                            builder.addFormDataPart("label", "Z-UISI")
                            builder.addFormDataPart("label", "android")
                            builder.addFormDataPart("label", "uisi-recipient")
                        }
                        ReportType.AUTO_UISI_SENDER -> {
                            builder.addFormDataPart("label", "Z-UISI")
                            builder.addFormDataPart("label", "android")
                            builder.addFormDataPart("label", "uisi-sender")
                        }
                    }

                    if (crashCallStack.isNotEmpty() && withCrashLogs) {
                        builder.addFormDataPart("label", "crash")
                    }

                    val requestBody = builder.build()

                    // add a progress listener
                    requestBody.setWriteListener { totalWritten, contentLength ->
                        val percentage = if (-1L != contentLength) {
                            if (totalWritten > contentLength) {
                                100
                            } else {
                                (totalWritten * 100 / contentLength).toInt()
                            }
                        } else {
                            0
                        }

                        if (isCancelled && null != bugReportCall) {
                            bugReportCall!!.cancel()
                        }

                        Timber.v("## onWrite() : $percentage%")
                        try {
                            listener?.onProgress(percentage)
                        } catch (e: Exception) {
                            Timber.e(e, "## onProgress() : failed")
                        }
                    }

                    // build the request
                    val request = Request.Builder()
                        .url(context.getString(R.string.bug_report_url))
                        .post(requestBody)
                        .build()

                    var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR
                    var response: Response? = null
                    var errorMessage: String? = null

                    // trigger the request
                    try {
                        bugReportCall = okHttpClient.get().newCall(request)
                        response = bugReportCall!!.execute()
                        responseCode = response.code
                    } catch (e: CancellationException) {
                        throw e
                    } catch (e: Exception) {
                        Timber.e(e, "response")
                        errorMessage = e.localizedMessage
                    }

                    // if the upload failed, try to retrieve the reason
                    if (responseCode != HttpURLConnection.HTTP_OK) {
                        if (null != errorMessage) {
                            serverError = "Failed with error $errorMessage"
                        } else if (response?.body == null) {
                            serverError = "Failed with error $responseCode"
                        } else {
                            try {
                                val inputStream = response.body!!.byteStream()

                                serverError = inputStream.use {
                                    buildString {
                                        var ch = it.read()
                                        while (ch != -1) {
                                            append(ch.toChar())
                                            ch = it.read()
                                        }
                                    }
                                }

                                // check if the error message
                                serverError?.let {
                                    try {
                                        val responseJSON = JSONObject(it)
                                        serverError = responseJSON.getString("error")
                                    } catch (e: CancellationException) {
                                        throw e
                                    } catch (e: JSONException) {
                                        Timber.e(e, "doInBackground ; Json conversion failed")
                                    }
                                }

                                // should never happen
                                if (null == serverError) {
                                    serverError = "Failed with error $responseCode"
                                }
                            } catch (e: CancellationException) {
                                throw e
                            } catch (e: Exception) {
                                Timber.e(e, "## sendBugReport() : failed to parse error")
                            }
                        }
                    } else {
                        /*
                    reportURL = response?.body?.string()?.let { stringBody ->
                        adapter.fromJson(stringBody)?.get("report_url")?.toString()
                    }
                     */
                    }
                }
            }

            withContext(coroutineDispatchers.main) {
                bugReportCall = null

                if (null != listener) {
                    try {
                        if (isCancelled) {
                            listener.onUploadCancelled()
                        } else if (null == serverError) {
                            listener.onUploadSucceed(reportURL)
                        } else {
                            listener.onUploadFailed(serverError)
                        }
                    } catch (e: CancellationException) {
                        throw e
                    } catch (e: Exception) {
                        Timber.e(e, "## onPostExecute() : failed")
                    }
                }
            }
        } finally {
            // delete the generated files when the bug report process has finished
            for (file in bugReportFiles) {
                file.safeDelete()
            }
        }
    }

    /**
     * Send a bug report either with email or with Vector.
     */
    /* TODO Remove
    fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) {
        screenshot = takeScreenshot(activity)
        logDbInfo()
        logProcessInfo()
        logOtherInfo()
        activity.startActivity(BugReportActivity.intent(activity, reportType))
    }
     */

    // private fun logOtherInfo() {
    //    Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState())
    // }

    // private fun logDbInfo() {
    //    val dbInfo = matrix.debugService().getDbUsageInfo()
    //    Timber.i(dbInfo)
    // }

    // private fun logProcessInfo() {
    //    val pInfo = processInfo.getInfo()
    //    Timber.i(pInfo)
    // }

    private fun rageShakeAppNameForReport(reportType: ReportType): String {
        // As per https://github.com/matrix-org/rageshake
        // app: Identifier for the application (eg 'riot-web').
        // Should correspond to a mapping configured in the configuration file for github issue reporting to work.
        // (see R.string.bug_report_url for configured RS server)
        return context.getString(
            when (reportType) {
                ReportType.AUTO_UISI_SENDER,
                ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name
                else -> R.string.bug_report_app_name
            }
        )
    }

    override fun logDirectory(): File {
        return File(context.cacheDir, LOG_DIRECTORY_NAME)
    }

    override fun cleanLogDirectoryIfNeeded() {
        coroutineScope.launch(coroutineDispatchers.io) {
            // delete the log files older than 1 day, except the most recent one
            deleteOldLogFiles(systemClock.epochMillis() - DAY_IN_MILLIS)
        }
    }

    /**
     * @return the files on the log directory.
     */
    private fun getLogFiles(): List<File> {
        return tryOrNull(
            onError = { Timber.e(it, "## getLogFiles() failed") }
        ) {
            val logDirectory = logDirectory()
            logDirectory.listFiles()?.toList()
        }.orEmpty()
    }

    /**
     * Delete the log files older than the given time except the most recent one.
     * @param time the time in ms
     */
    private fun deleteOldLogFiles(time: Long) {
        val logFiles = getLogFiles()
        val oldLogFiles = logFiles.filter { it.lastModified() < time }
        oldLogFiles.deleteAllExceptMostRecent()
    }

    /**
     * Delete all the log files except the most recent one.
     *
     */
    private fun List<File>.deleteAllExceptMostRecent() {
        if (size > 1) {
            val mostRecentFile = maxByOrNull { it.lastModified() }
            forEach { file ->
                if (file != mostRecentFile) {
                    file.safeDelete()
                }
            }
        }
    }

    // ==============================================================================================================
    // Logcat management
    // ==============================================================================================================

    /**
     * Save the logcat.
     *
     * @param isErrorLogcat true to save the error logcat
     * @return the file if the operation succeeds
     */
    private fun saveLogCat(isErrorLogcat: Boolean): File? {
        val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME)

        if (logCatErrFile.exists()) {
            logCatErrFile.safeDelete()
        }

        try {
            logCatErrFile.writer().use {
                getLogCatError(it, isErrorLogcat)
            }

            return compressFile(logCatErrFile)
        } catch (error: OutOfMemoryError) {
            Timber.e(error, "## saveLogCat() : fail to write logcat OOM")
        } catch (e: Exception) {
            Timber.e(e, "## saveLogCat() : fail to write logcat")
        } finally {
            if (logCatErrFile.exists()) {
                logCatErrFile.safeDelete()
            }
        }

        return null
    }

    /**
     * Retrieves the logs.
     *
     * @param streamWriter the stream writer
     * @param isErrorLogCat true to save the error logs
     */
    private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) {
        val logcatProc: Process

        try {
            logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) logcatCommandError else logcatCommandDebug)
        } catch (e1: IOException) {
            return
        }

        try {
            val separator = System.getProperty("line.separator")
            logcatProc.inputStream
                .reader()
                .buffered(BUFFER_SIZE)
                .forEachLine { line ->
                    streamWriter.append(line)
                    streamWriter.append(separator)
                }
        } catch (e: IOException) {
            Timber.e(e, "getLog fails")
        }
    }
}
