/*
 * SPDX-FileCopyrightText: 2020, microG Project Team
 * SPDX-License-Identifier: Apache-2.0
 * Notice: Portions of this file are reproduced from work created and shared by Google and used
 *         according to terms described in the Creative Commons 4.0 Attribution License.
 *         See https://developers.google.com/readme/policies for details.
 */

package com.google.android.gms.vision;

import android.content.Context;
import android.graphics.ImageFormat;
import android.graphics.SurfaceTexture;
import android.hardware.Camera;
import android.os.SystemClock;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.WindowManager;

import androidx.annotation.GuardedBy;

import com.google.android.gms.common.images.Size;

import org.microg.gms.common.PublicApi;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;

/**
 * Manages the camera in conjunction with an underlying {@link Detector}. This receives preview frames from the camera
 * at a specified rate, sending those frames to the detector as fast as it is able to process those frames.
 * <p>
 * This camera source makes a best effort to manage processing on preview frames as fast as possible, while at the same
 * time minimizing lag. As such, frames may be dropped if the detector is unable to keep up with the rate of frames
 * generated by the camera. You should use {@link CameraSource.Builder#setRequestedFps(float)} to specify a frame rate
 * that works well with the capabilities of the camera hardware and the detector options that you have selected.
 * If CPU utilization is higher than you'd like, then you may want to consider reducing FPS.
 * If the camera preview or detector results are too "jerky", then you may want to consider increasing FPS.
 * <p>
 * The following Android permission is required to use the camera:
 * <ul>
 * <li>android.permissions.CAMERA</li>
 * </ul>
 */
@PublicApi
public class CameraSource {
    public static final int CAMERA_FACING_BACK = 0;
    public static final int CAMERA_FACING_FRONT = 1;

    private final Object cameraLock = new Object();
    @GuardedBy("cameraLock")
    private Camera camera;
    private Context context;
    private int facing = CAMERA_FACING_BACK;
    private boolean autoFocusEnabled;
    private String focusMode;
    private float requestedFps = 30;
    private int requestedWidth = 1024;
    private int requestedHeight = 768;
    private Size previewSize;
    private int rotation;
    private Thread detectorThread;
    private DetectorRunner detectorRunner = new DetectorRunner();
    private Detector<?> detector;

    /**
     * Returns the selected camera; one of {@link #CAMERA_FACING_BACK} or {@link #CAMERA_FACING_FRONT}.
     */
    public int getCameraFacing() {
        return facing;
    }

    /**
     * Returns the preview size that is currently in use by the underlying camera.
     */
    public Size getPreviewSize() {
        return previewSize;
    }

    /**
     * Stops the camera and releases the resources of the camera and underlying detector.
     */
    public void release() {
        synchronized (cameraLock) {
            stop();
            if (detector != null) detector.release();
        }
    }

    /**
     * Opens the camera and starts sending preview frames to the underlying detector. The preview frames are not displayed.
     *
     * @throws IOException if the camera's preview texture or display could not be initialized
     */
    public CameraSource start() throws IOException {
        synchronized (cameraLock) {
            if (camera == null) {
                camera = createCamera();
                camera.setPreviewTexture(new SurfaceTexture(100));
                camera.startPreview();
                startDetectorThread();
            }
            return this;
        }
    }

    /**
     * Opens the camera and starts sending preview frames to the underlying detector.
     * The supplied surface holder is used for the preview so frames can be displayed to the user.
     *
     * @param surfaceHolder the surface holder to use for the preview frames
     * @throws IOException if the supplied surface holder could not be used as the preview display
     */
    public CameraSource start(SurfaceHolder surfaceHolder) throws IOException {
        synchronized (cameraLock) {
            if (camera == null) {
                camera = createCamera();
                camera.setPreviewDisplay(surfaceHolder);
                camera.startPreview();
                startDetectorThread();
            }
            return this;
        }
    }

    /**
     * Closes the camera and stops sending frames to the underlying frame detector.
     * <p>
     * This camera source may be restarted again by calling {@link #start()} or {@link #start(SurfaceHolder)}.
     * <p>
     * Call {@link #release()} instead to completely shut down this camera source and release the resources of the underlying detector.
     */
    public void stop() {
        synchronized (cameraLock) {
            detectorRunner.setActive(false);
            if (detectorThread != null) {
                try {
                    detectorThread.join();
                } catch (InterruptedException e) {
                    // Ignore
                }
                detectorThread = null;
            }
            if (camera != null) {
                camera.stopPreview();
                camera.setPreviewCallbackWithBuffer(null);
                try {
                    camera.setPreviewTexture(null);
                } catch (IOException e) {
                    // Ignore
                }
                try {
                    camera.setPreviewDisplay(null);
                } catch (IOException e) {
                    // Ignore
                }
                camera.release();
                camera = null;
            }
        }
    }

    /**
     * Initiates taking a picture, which happens asynchronously.
     * The camera source should have been activated previously with {@link #start()} or {@link #start(SurfaceHolder)}.
     * The camera preview is suspended while the picture is being taken, but will resume once picture taking is done.
     *
     * @param shutter the callback for image capture moment, or null
     * @param jpeg    the callback for JPEG image data, or null
     */
    public void takePicture(CameraSource.ShutterCallback shutter, CameraSource.PictureCallback jpeg) {
        synchronized (this.cameraLock) {
            if (camera != null) {
                camera.takePicture(shutter::onShutter, null, null, (data, camera) -> jpeg.onPictureTaken(data));
            }
        }
    }

    /**
     * Builder for configuring and creating an associated camera source.
     */
    public static class Builder {
        private CameraSource cameraSource = new CameraSource();

        /**
         * Creates a camera source builder with the supplied context and detector.
         * Camera preview images will be streamed to the associated detector upon starting the camera source.
         */
        public Builder(Context context, Detector<?> detector) {
            if (context == null) throw new IllegalArgumentException("No context supplied.");
            if (detector == null) throw new IllegalArgumentException("No detector supplied.");
            this.cameraSource.context = context;
            this.cameraSource.detector = detector;
        }

        /**
         * Creates an instance of the camera source.
         */
        public CameraSource build() {
            return cameraSource;
        }

        /**
         * Sets whether to enable camera auto focus. If set to false (default), the camera's default focus setting is used. If set to true, a continuous video focus setting is used (if supported by the camera hardware).
         * Default: false.
         */
        public Builder setAutoFocusEnabled(boolean autoFocusEnabled) {
            this.cameraSource.autoFocusEnabled = autoFocusEnabled;
            return this;
        }

        /**
         * Sets the camera to use (either {@link CameraSource#CAMERA_FACING_BACK} or {@link CameraSource#CAMERA_FACING_FRONT}).
         * Default: back facing.
         */
        public Builder setFacing(int facing) {
            this.cameraSource.facing = facing;
            return this;
        }

        /**
         * Sets which FocusMode will be used for camera focus. Only FOCUS_MODE_CONTINUOUS_PICTURE and FOCUS_MODE_CONTINUOUS_VIDEO are supported for now.
         */
        public Builder setFocusMode(String focusMode) {
            this.cameraSource.focusMode = focusMode;
            return this;
        }

        /**
         * Sets the requested frame rate in frames per second. If the exact requested value is not not available, the best matching available value is selected.
         * Default: 30.
         */
        public Builder setRequestedFps(float fps) {
            this.cameraSource.requestedFps = fps;
            return this;
        }

        /**
         * Sets the desired width and height of the camera frames in pixels. If the exact desired values are not available options, the best matching available options are selected. Also, we try to select a preview size which corresponds to the aspect ratio of an associated full picture size, if applicable.
         * Default: 1024x768.
         */
        public Builder setRequestedPreviewSize(int width, int height) {
            this.cameraSource.requestedWidth = width;
            this.cameraSource.requestedHeight = height;
            return this;
        }
    }

    /**
     * Callback interface used to supply image data from a photo capture.
     */
    public interface PictureCallback {
        /**
         * Called when image data is available after a picture is taken. The format of the data is a jpeg binary.
         */
        void onPictureTaken(byte[] data);
    }

    /**
     * Callback interface used to signal the moment of actual image capture.
     */
    public interface ShutterCallback {
        /**
         * Called as near as possible to the moment when a photo is captured from the sensor. This is a good opportunity to play a shutter sound or give other feedback of camera operation. This may be some time after the photo was triggered, but some time before the actual data is available.
         */
        void onShutter();
    }

    private void startDetectorThread() {
        if (detectorThread != null && detectorThread.isAlive()) return;
        detectorThread = new Thread(detectorRunner);
        detectorThread.setName("gms.vision.CameraSource");
        detectorRunner.setActive(true);
        detectorThread.start();
    }

    private Camera createCamera() throws IOException {
        int cameraId = getFacingCameraId();
        Camera camera = Camera.open(cameraId);
        try {
            Camera.Parameters parameters = camera.getParameters();

            // Set size
            SizePair size = pickCameraSize(camera);
            previewSize = size.preview;
            parameters.setPreviewSize(previewSize.getWidth(), previewSize.getHeight());
            if (size.picture != null) {
                parameters.setPictureSize(size.picture.getWidth(), size.picture.getHeight());
            }

            // Set FPS
            int[] fpsRange = pickFpsRange(camera);
            parameters.setPreviewFpsRange(fpsRange[0], fpsRange[1]);

            // Set focus mode
            if (autoFocusEnabled && (focusMode == null || !parameters.getSupportedFocusModes().contains(focusMode))) {
                if (parameters.getSupportedFocusModes().contains("continuous-video")) {
                    focusMode = "continuous-video";
                } else {
                    focusMode = null;
                }
            }
            if (focusMode != null && parameters.getSupportedFocusModes().contains(focusMode)) {
                parameters.setFocusMode(focusMode);
            }

            // Handle rotation
            WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
            int displayRotation = 0;
            switch (windowManager.getDefaultDisplay().getRotation()) {
                case Surface.ROTATION_90: displayRotation = 90; break;
                case Surface.ROTATION_180: displayRotation = 180; break;
                case Surface.ROTATION_270: displayRotation = 270; break;
            }
            Camera.CameraInfo cameraInfo = new Camera.CameraInfo();
            Camera.getCameraInfo(cameraId, cameraInfo);
            if (facing == CAMERA_FACING_FRONT) {
                rotation = ((cameraInfo.orientation + displayRotation) % 360) / 90;
                camera.setDisplayOrientation((360 - (cameraInfo.orientation + displayRotation)) % 360);
            } else {
                rotation = ((cameraInfo.orientation - displayRotation + 360) % 360) / 90;
                camera.setDisplayOrientation((cameraInfo.orientation - displayRotation + 360) % 360);
            }

            parameters.setRotation(rotation * 90);
            parameters.setPreviewFormat(ImageFormat.NV21);
            camera.setParameters(parameters);

            // Add processing
            camera.setPreviewCallbackWithBuffer(new Camera.PreviewCallback() {
                @Override
                public void onPreviewFrame(byte[] data, Camera frameCamera) {
                    detectorRunner.onPreviewFrame(data, frameCamera);
                }
            });
            camera.addCallbackBuffer(new byte[(int) Math.ceil((double) ((long) (previewSize.getHeight() * previewSize.getWidth() * ImageFormat.getBitsPerPixel(parameters.getPreviewFormat()))) / 8.0D) + 1]);

            return camera;
        } catch (Exception e) {
            camera.release();
            throw e;
        }
    }

    private int getFacingCameraId() throws IOException {
        Camera.CameraInfo info = new Camera.CameraInfo();
        for (int i = 0; i < Camera.getNumberOfCameras(); i++) {
            Camera.getCameraInfo(i, info);
            if (info.facing == facing) {
                return i;
            }
        }
        throw new IOException("Could not find requested camera.");
    }

    private int[] pickFpsRange(Camera camera) throws IOException {
        int requestsFpms = (int) (requestedFps * 1000.0F);
        int[] selectedFpsRange = null;
        int selectedPreviewFpsOffset = Integer.MAX_VALUE;

        for (int[] previewFpsRange : camera.getParameters().getSupportedPreviewFpsRange()) {
            int minOffset = requestsFpms - previewFpsRange[0];
            int maxOffset = requestsFpms - previewFpsRange[1];
            int previewFpsOffset;
            if ((previewFpsOffset = Math.abs(minOffset) + Math.abs(maxOffset)) < selectedPreviewFpsOffset) {
                selectedFpsRange = previewFpsRange;
                selectedPreviewFpsOffset = previewFpsOffset;
            }
        }
        if (selectedFpsRange == null) throw new IOException("Could not find suitable preview frames per second range.");
        return selectedFpsRange;
    }

    private SizePair pickCameraSize(Camera camera) throws IOException {
        ArrayList<SizePair> sizeCandidates = new ArrayList<>();
        for (Camera.Size previewSize : camera.getParameters().getSupportedPreviewSizes()) {
            float previewAspectRatio = (float) (previewSize).width / (float) previewSize.height;
            for (Camera.Size pictureSize : camera.getParameters().getSupportedPictureSizes()) {
                float pictureAspectRatio = (float) (pictureSize).width / (float) pictureSize.height;
                if (Math.abs(previewAspectRatio - pictureAspectRatio) < 0.01F) { // Approximately same aspect ratio
                    sizeCandidates.add(new SizePair(previewSize, pictureSize));
                    break;
                }
            }
        }
        if (sizeCandidates.size() == 0) {
            for (Camera.Size previewSize : camera.getParameters().getSupportedPreviewSizes()) {
                sizeCandidates.add(new SizePair(previewSize, null));
            }
        }

        SizePair sizePair = null;
        int selectedSizeOffset = Integer.MAX_VALUE;
        int sizeOffset;
        for (SizePair candidate : sizeCandidates) {
            Size candidatePreviewSize = candidate.preview;
            sizeOffset = Math.abs(candidatePreviewSize.getWidth() - requestedWidth) + Math.abs(candidatePreviewSize.getHeight() - requestedHeight);
            if (sizeOffset < selectedSizeOffset) {
                sizePair = candidate;
                selectedSizeOffset = sizeOffset;
            }
        }

        if (sizePair == null) throw new IOException("Could not find suitable preview size.");
        return sizePair;
    }

    private static class SizePair {
        Size preview;
        Size picture;

        public SizePair(Camera.Size preview, Camera.Size picture) {
            this.preview = new Size(preview.width, preview.height);
            if (picture != null) {
                this.picture = new Size(picture.width, picture.height);
            }
        }
    }

    private class DetectorRunner implements Runnable {
        private final Object lock = new Object();
        private long startElapsedRealtime = SystemClock.elapsedRealtime();
        private long timestampMillis;
        private byte[] frameData;
        private ByteBuffer frameBuffer;
        private ByteBuffer frameDataBuffer;
        private int frameId = 0;
        private boolean active;

        public void setActive(boolean active) {
            synchronized (lock) {
                this.active = active;
                lock.notifyAll();
            }
        }

        @Override
        public void run() {
            while (true) {
                Frame frame;
                synchronized (lock) {
                    while (active && frameBuffer == null) {
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            return;
                        }
                    }

                    if (!active) return;

                    frame = new Frame.Builder().setId(frameId).setImageData(frameBuffer, previewSize.getWidth(), previewSize.getHeight(), ImageFormat.NV21).setTimestampMillis(timestampMillis).setRotation(rotation).build();
                    frameBuffer = null;
                }
                try {
                    detector.receiveFrame(frame);
                } catch (Exception e) {
                    // Ignore
                } finally {
                    camera.addCallbackBuffer(frameData);
                }
            }
        }

        public void onPreviewFrame(byte[] data, Camera camera) {
            synchronized (lock) {
                if (frameData == null) {
                    frameData = data;
                    frameDataBuffer = ByteBuffer.wrap(data);
                }
                if (data == frameData) {
                    frameId++;
                    timestampMillis = SystemClock.elapsedRealtime();
                    frameBuffer = frameDataBuffer;
                    lock.notifyAll();
                }
            }
        }
    }
}
