/*
 * Copyright 2020 The Android Open Source Project
 *
 * 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
 *
 *     https://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 com.example.android.camera2.basic.fragments

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.graphics.ImageFormat
import android.hardware.camera2.*
import android.hardware.camera2.CameraCaptureSession.CaptureCallback
import android.media.Image
import android.media.ImageReader
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.util.Log
import android.view.*
import android.widget.ImageView
import android.widget.Toast
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.setPadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.tracing.trace
import androidx.tracing.traceAsync
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.example.android.camera.utils.AutoFitSurfaceView
import com.example.android.camera.utils.OrientationLiveData
import com.example.android.camera.utils.getPreviewOutputSize
import com.example.android.camera2.basic.CameraActivity
import com.example.android.camera2.basic.R
import kotlinx.android.synthetic.main.fragment_camera.*
import kotlinx.coroutines.*
import java.io.*
import java.lang.Runnable
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.ThreadFactory
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

class CameraFragment : Fragment() {

    /** AndroidX navigation arguments */
    private val args: CameraFragmentArgs by navArgs()

    /** Detects, characterizes, and connects to a CameraDevice (used for all camera operations) */
    private val cameraManager: CameraManager by lazy {
        val context = requireContext().applicationContext
        context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
    }

    /** [CameraCharacteristics] corresponding to the provided Camera ID */
    private val characteristics: CameraCharacteristics by lazy {
        cameraManager.getCameraCharacteristics(args.cameraId)
    }

    /** Readers used as buffers for camera still shots */
    private lateinit var imageReader: ImageReader

    /** [HandlerThread] where all camera operations run */
    private val cameraThread = HandlerThread("CameraThread").apply { start() }

    /** [Handler] corresponding to [cameraThread] */
    private val cameraHandler = Handler(cameraThread.looper)

    private val cameraDispatcher: CoroutineDispatcher by lazy {
        Executor {
            if (!cameraHandler.post(it)) {
                throw RejectedExecutionException("Camera handler shutting down.")
            }
        }.asCoroutineDispatcher()
    }

    /** [Handler] corresponding to main thread */
    private val mainThreadHandler = Handler(Looper.getMainLooper())

    /** Performs recording animation of flashing screen */
    private val animationTask: Runnable by lazy {
        Runnable {
            // Flash white animation
            overlay.background = Color.argb(150, 255, 255, 255).toDrawable()
            // Wait for ANIMATION_FAST_MILLIS
            overlay.postDelayed({
                // Remove white flash animation
                overlay.background = null
            }, CameraActivity.ANIMATION_FAST_MILLIS)
        }
    }

    /** Where the camera preview is displayed */
    private lateinit var viewFinder: AutoFitSurfaceView

    /** Overlay on top of the camera preview */
    private lateinit var overlay: View

    /** The [CameraDevice] that will be opened in this fragment */
    private lateinit var camera: CameraDevice

    /** Internal reference to the ongoing [CameraCaptureSession] configured with our parameters */
    private lateinit var session: CameraCaptureSession

    /** Live data listener for changes in the device orientation relative to the camera */
    private lateinit var relativeOrientation: OrientationLiveData

    private val requestQueue = ArrayDeque<Pair<UUID, CancellableContinuation<CombinedCaptureResult>>>()
    private val inFlightRequests = HashMap<UUID, CancellableContinuation<CombinedCaptureResult>>()

    private val completeImages = TreeMap<Long, Image>()
    private val completeResults = TreeMap<Long, TotalCaptureResult>()

    override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
    ): View? = inflater.inflate(R.layout.fragment_camera, container, false)

    @SuppressLint("MissingPermission")
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        overlay = view.findViewById(R.id.overlay)
        viewFinder = view.findViewById(R.id.view_finder)

        viewFinder.holder.addCallback(object : SurfaceHolder.Callback {
            override fun surfaceDestroyed(holder: SurfaceHolder) = Unit

            override fun surfaceChanged(
                    holder: SurfaceHolder,
                    format: Int,
                    width: Int,
                    height: Int) = Unit

            override fun surfaceCreated(holder: SurfaceHolder) {

                // Selects appropriate preview size and configures view finder
                val previewSize = getPreviewOutputSize(
                        viewFinder.display, characteristics, SurfaceHolder::class.java)
                Log.d(TAG, "View finder size: ${viewFinder.width} x ${viewFinder.height}")
                Log.d(TAG, "Selected preview size: $previewSize")
                viewFinder.setAspectRatio(previewSize.width, previewSize.height)

                // To ensure that size is set, initialize camera in the view's thread
                view.post { initializeCamera() }
            }
        })

        // Used to rotate the output media to match device orientation
        relativeOrientation = OrientationLiveData(requireContext(), characteristics).apply {
            observe(viewLifecycleOwner, Observer {
                orientation -> Log.d(TAG, "Orientation changed: $orientation")
            })
        }
    }

    /**
     * Begin all camera operations in a coroutine in the camera thread. This function:
     * - Opens the camera
     * - Configures the camera session
     * - Starts the preview by dispatching a repeating capture request
     * - Sets up the still image capture listeners
     */
    private fun initializeCamera() = lifecycleScope.launch(cameraDispatcher) {
        // Open the selected camera
        camera = openCamera(cameraManager, args.cameraId, cameraHandler)

        // Initialize an image reader which will be used to capture still photos
        val size = characteristics.get(
                CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
                .getOutputSizes(args.pixelFormat).maxBy { it.height * it.width }!!
        imageReader = ImageReader.newInstance(
                size.width, size.height, args.pixelFormat, IMAGE_BUFFER_SIZE)

        // Creates list of Surfaces where the camera will output frames
        val targets = listOf(viewFinder.holder.surface, imageReader.surface)

        // Start a capture session using our open camera and list of Surfaces where frames will go
        session = createCaptureSession(camera, targets, cameraHandler)

        val captureRequest = camera.createCaptureRequest(
                CameraDevice.TEMPLATE_PREVIEW).apply { addTarget(viewFinder.holder.surface) }

        // This will keep sending the capture request as frequently as possible until the
        // session is torn down or session.stopRepeating() is called
        session.setRepeatingRequest(captureRequest.build(), null, cameraHandler)

        imageReader.setOnImageAvailableListener({ reader ->
            val image = reader.acquireNextImage()
            Log.d(TAG, "Image available in queue: ${image.timestamp}")
            tryMatchImage(image)
        }, mainThreadHandler)

        // Listen to the capture button
        capture_button.setOnClickListener {
            lifecycleScope.launch(Dispatchers.Main) {
                var result: CombinedCaptureResult? = null

                traceAsync("takePhoto", UUID.randomUUID().hashCode()) {
                    withTimeout(IMAGE_CAPTURE_TIMEOUT_MILLIS) {
                        result = takePhoto()
                    }
                }

                if (result != null) {
                    // Perform I/O heavy operations in a different scope
                    withContext(IO_DISPATCHER) {
                        trace("saveToDisk") {
                            // Save the result to disk
                            result!!.use {
                                val output = saveResult(it)
                                Log.d(TAG, "Image saved: ${output.absolutePath}")
                            }
                        }
                    }
                }
            }
        }
    }

    /** Opens the camera and returns the opened device (as the result of the suspend coroutine) */
    @SuppressLint("MissingPermission")
    private suspend fun openCamera(
            manager: CameraManager,
            cameraId: String,
            handler: Handler? = null
    ): CameraDevice = suspendCancellableCoroutine { cont ->
        manager.openCamera(cameraId, object : CameraDevice.StateCallback() {
            override fun onOpened(device: CameraDevice) = cont.resume(device)

            override fun onClosed(camera: CameraDevice) {
                imageReader.acquireLatestImage()?.use { }
            }

            override fun onDisconnected(device: CameraDevice) {
                Log.w(TAG, "Camera $cameraId has been disconnected")
                requireActivity().finish()
            }

            override fun onError(device: CameraDevice, error: Int) {
                val msg = when (error) {
                    ERROR_CAMERA_DEVICE -> "Fatal (device)"
                    ERROR_CAMERA_DISABLED -> "Device policy"
                    ERROR_CAMERA_IN_USE -> "Camera in use"
                    ERROR_CAMERA_SERVICE -> "Fatal (service)"
                    ERROR_MAX_CAMERAS_IN_USE -> "Maximum cameras in use"
                    else -> "Unknown"
                }
                val exc = RuntimeException("Camera $cameraId error: ($error) $msg")
                Log.e(TAG, exc.message, exc)
                if (cont.isActive) cont.resumeWithException(exc)
            }
        }, handler)
    }

    /**
     * Starts a [CameraCaptureSession] and returns the configured session (as the result of the
     * suspend coroutine
     */
    private suspend fun createCaptureSession(
            device: CameraDevice,
            targets: List<Surface>,
            handler: Handler? = null
    ): CameraCaptureSession = suspendCoroutine { cont ->

        // Create a capture session using the predefined targets; this also involves defining the
        // session state callback to be notified of when the session is ready
        device.createCaptureSession(targets, object: CameraCaptureSession.StateCallback() {

            override fun onConfigured(session: CameraCaptureSession) = cont.resume(session)

            override fun onConfigureFailed(session: CameraCaptureSession) {
                val exc = RuntimeException("Camera ${device.id} session configuration failed")
                Log.e(TAG, exc.message, exc)
                cont.resumeWithException(exc)
            }
        }, handler)
    }

    /**
     * Helper function used to capture a still image using the [CameraDevice.TEMPLATE_STILL_CAPTURE]
     * template. It performs synchronization between the [CaptureResult] and the [Image] resulting
     * from the single capture, and outputs a [CombinedCaptureResult] object.
     */
    private suspend fun takePhoto(): CombinedCaptureResult = suspendCancellableCoroutine { cont ->
        val uuid = UUID.randomUUID()
        requestQueue.addLast(Pair(uuid, cont))
        cont.invokeOnCancellation {
            lifecycleScope.launch(Dispatchers.Main) {
                if (inFlightRequests.remove(uuid) != null) {
                    Log.e(TAG, "Image capture timed out: $uuid")
                    Toast.makeText(requireContext(),
                            "Image capture timed out: $uuid", Toast.LENGTH_SHORT).show()
                    trySendNextRequests()
                }
            }
        }
        trySendNextRequests()
    }

    private fun trySendNextRequests() {
        while (inFlightRequests.size < MAX_IN_FLIGHT_REQUESTS && !requestQueue.isEmpty()) {
            // Send the next non-cancelled continuation
            val (uuid, cont) = requestQueue.removeFirst()
            if (cont.isCancelled) continue

            Log.i(TAG, "Sending request: $uuid")
            val orientation = relativeOrientation.value
            inFlightRequests[uuid] = cont

            lifecycleScope.launch(cameraDispatcher) {
                val captureRequest = session.device.createCaptureRequest(
                        CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
                    addTarget(imageReader.surface)
                    set(CaptureRequest.JPEG_ORIENTATION, orientation)
                    set(CaptureRequest.JPEG_QUALITY, 95)
                    setTag(uuid)
                }.build()
                session.capture(captureRequest, object : CaptureCallback() {
                    override fun onCaptureStarted(
                            session: CameraCaptureSession,
                            request: CaptureRequest,
                            timestamp: Long,
                            frameNumber: Long) {
                        lifecycleScope.launch {
                            animationTask.run()
                        }
                    }

                    override fun onCaptureCompleted(
                            session: CameraCaptureSession,
                            request: CaptureRequest,
                            result: TotalCaptureResult) {
                        tryMatchCaptureResult(result)
                    }

                    override fun onCaptureFailed(
                            session: CameraCaptureSession,
                            request: CaptureRequest,
                            failure: CaptureFailure) {
                        val inFlightCont = inFlightRequests.remove(request.tag)
                        if (inFlightCont != null) {
                            inFlightCont.resumeWithException(CaptureFailureException(failure))
                            trySendNextRequests()
                        }
                    }
                }, mainThreadHandler)
            }
        }
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private fun tryMatchCaptureResult(result: TotalCaptureResult) {
        val timestamp: Long = result.get(CaptureResult.SENSOR_TIMESTAMP)!!
        val uuid = result.request.tag as UUID
        val iter = completeImages.iterator()
        while (iter.hasNext()) {
            val entry = iter.next()
            // First close all images older than timestamp
            if (entry.key < timestamp) {
                entry.value.close()
                iter.remove()
            } else if (entry.key == timestamp) {
                val inFlightCont = inFlightRequests.remove(uuid)
                val image = entry.value
                if (inFlightCont != null) {
                    // Match. Complete coroutine.
                    Log.i(TAG, "[RESULT MATCH] Resuming UUID: $uuid, Timestamp: $timestamp")
                    iter.remove()
                    inFlightCont.resume(CombinedCaptureResult(image, result)) {
                        // In case coroutine was cancelled, close the image
                        image.close()
                    }

                    if (!inFlightCont.isCancelled) {
                        // We completed successfully, try to start new request.
                        trySendNextRequests()
                    }
                } else {
                    // In-flight request cancelled. Close image.
                    iter.remove()
                    image.close()
                }

                // We've matched. No need to continue iterating.
                return
            } else {
                // Only newer images found; request was likely cancelled.
                // Stop searching.
                return
            }
        }

        // Stash result to be matched later in tryMatchImage()
        completeResults[timestamp] = result
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    private fun tryMatchImage(image: Image) {
        val timestamp = image.timestamp
        val iter = completeResults.iterator()
        while (iter.hasNext()) {
            val entry = iter.next()
            // First remove any results older than image
            if (entry.key < timestamp) {
                iter.remove()
            } else if (entry.key == timestamp) {
                val uuid = entry.value.request.tag as UUID
                val inFlightCont = inFlightRequests.remove(uuid)
                if (inFlightCont != null) {
                    // Match. Complete coroutine.
                    Log.i(TAG, "[IMAGE MATCH] Resuming UUID: $uuid, Timestamp: $timestamp")
                    val result = entry.value
                    iter.remove()
                    inFlightCont.resume(CombinedCaptureResult(image, result)) {
                        // In case coroutine was cancelled, close the image
                        image.close()
                    }

                    if (!inFlightCont.isCancelled) {
                        // We completed successfully, try to start new request.
                        trySendNextRequests()
                    }
                } else {
                    // In-flight request cancelled. Close image.
                    iter.remove()
                    image.close()
                }

                // We've matched. No need to continue iterating.
                return
            } else {
                // Only newer results found; request was likely cancelled.
                // Close image and stop searching.
                image.close()
                return
            }
        }

        // Stash image to be matched later in tryMatchCaptureResult()
        completeImages[timestamp] = image
    }

    /** Helper function used to save a [CombinedCaptureResult] into a [File] */
    private fun saveResult(result: CombinedCaptureResult): File {
        when (result.image.format) {

            // When the format is JPEG or DEPTH JPEG we can simply save the bytes as-is
            ImageFormat.JPEG, ImageFormat.DEPTH_JPEG -> {
                val buffer = result.image.planes[0].buffer
                val bytes = ByteArray(buffer.remaining()).apply { buffer.get(this) }
                try {
                    val output = createFile(requireContext(), "jpg")
                    FileOutputStream(output).use {
                        it.write(bytes)
                    }
                    // If the folder selected is an external media directory, this is
                    // unnecessary but otherwise other apps will not be able to access our
                    // images unless we scan them using [MediaScannerConnection]
                    MediaScannerConnection.scanFile(
                            context,
                            arrayOf(output.absolutePath),
                            arrayOf("image/jpeg")
                    ) { _, uri ->
                        Log.d(TAG, "Image capture scanned into media store: $uri")
                    }

                    setGalleryThumbnail(Uri.fromFile(output))
                    return output
                } catch (exc: IOException) {
                    Log.e(TAG, "Unable to write JPEG image to file", exc)
                    throw exc
                }
            }

            // No other formats are supported by this sample
            else -> {
                val exc = RuntimeException("Unknown image format: ${result.image.format}")
                Log.e(TAG, exc.message, exc)
                throw exc
            }
        }
    }

    private fun setGalleryThumbnail(uri: Uri) {
        // Reference of the view that holds the gallery thumbnail
        val thumbnail = requireView().findViewById<ImageView>(R.id.thumbnail_view)

        // Run the operations in the view's thread
        thumbnail.post {

            // Remove thumbnail padding
            thumbnail.setPadding(resources.getDimension(R.dimen.stroke_small).toInt())

            // Load thumbnail into circular button using Glide
            Glide.with(thumbnail)
                    .load(uri)
                    .apply(RequestOptions.circleCropTransform())
                    .into(thumbnail)
        }
    }

    override fun onStop() {
        super.onStop()
        requestQueue.clear()
        lifecycleScope.launch(cameraDispatcher) {
            try {
                camera.close()
            } catch (exc: Throwable) {
                Log.e(TAG, "Error closing camera", exc)
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        cameraThread.quitSafely()
    }

    companion object {
        private val TAG = CameraFragment::class.java.simpleName

        /** Maximum number of images that will be held in the reader's buffer */
        private const val IMAGE_BUFFER_SIZE: Int = 3

        /** Maximum time allowed to wait for the result of an image capture */
        private const val IMAGE_CAPTURE_TIMEOUT_MILLIS: Long = 30000

        private const val MAX_IN_FLIGHT_REQUESTS: Int = 1

        /** Helper data class used to hold capture metadata with their associated image */
        data class CombinedCaptureResult(
                val image: Image,
                val metadata: CaptureResult
        ) : Closeable {
            override fun close() = image.close()
        }

        data class CaptureFailureException(val captureFailure: CaptureFailure) : Exception(
                "Capture failed"
        )

        private val IO_THREAD_ID = AtomicInteger(0)
        private const val NUM_IO_THREADS = 2

        private val IO_DISPATCHER: CoroutineDispatcher by lazy {
            Executors.newFixedThreadPool(
                    NUM_IO_THREADS,
                    ThreadFactory { runnable ->
                        val thread =  Thread(runnable)
                        thread.name = "io-thread-${IO_THREAD_ID.getAndIncrement()}"
                        return@ThreadFactory thread
                    }
            ).asCoroutineDispatcher()
        }

        /**
         * Create a [File] named a using formatted timestamp with the current date and time.
         *
         * @return [File] created.
         */
        private fun createFile(context: Context, extension: String): File {
            val sdf = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss_SSS", Locale.US)
            val mediaDir = context.externalMediaDirs.firstOrNull()?.let {
                File(it, context.resources.getString(R.string.app_name)).apply { mkdirs() }
            }
            return File(if (mediaDir != null && mediaDir.exists())
                mediaDir else context.filesDir, "IMG_${sdf.format(Date())}.$extension")
        }
    }
}
