package org.thoughtcrime.securesms.mediasend.camerax;

import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapRegionDecoder;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.hardware.Camera;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CameraMetadata;
import android.os.Build;
import android.util.Pair;
import android.util.Rational;
import android.util.Size;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.camera.camera2.internal.compat.CameraManagerCompat;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageProxy;

import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.Stopwatch;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Locale;

public class CameraXUtil {

  private static final String TAG = Log.tag(CameraXUtil.class);

  @RequiresApi(21)
  private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
                                                                        CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
                                                                        CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL};

  @RequiresApi(24)
  private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_24 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
                                                                           CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
                                                                           CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
                                                                           CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3};

  @RequiresApi(28)
  private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_28 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY,
                                                                           CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED,
                                                                           CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL,
                                                                           CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL,
                                                                           CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3};

  @SuppressWarnings("SuspiciousNameCombination")
  @RequiresApi(21)
  public static ImageResult toJpeg(@NonNull ImageProxy image, boolean flip) throws IOException {
    ImageProxy.PlaneProxy[] planes   = image.getPlanes();
    ByteBuffer              buffer   = planes[0].getBuffer();
    Rect                    cropRect = shouldCropImage(image) ? image.getCropRect() : null;
    byte[]                  data     = new byte[buffer.capacity()];
    int                     rotation = image.getImageInfo().getRotationDegrees();

    buffer.get(data);

    try {
      Pair<Integer, Integer> dimens = BitmapUtil.getDimensions(new ByteArrayInputStream(data));

      if (dimens.first != image.getWidth() && dimens.second != image.getHeight()) {
        Log.w(TAG, String.format(Locale.ENGLISH, "Decoded image dimensions differed from stated dimensions! Stated: %d x %d, Decoded: %d x %d",
                                                  image.getWidth(), image.getHeight(), dimens.first, dimens.second));
        Log.w(TAG, "Ignoring the stated rotation and rotating the crop rect 90 degrees (stated rotation is " + rotation + " degrees).");

        rotation = 0;

        if (cropRect != null) {
          cropRect = new Rect(cropRect.top, cropRect.left, cropRect.bottom, cropRect.right);
        }
      }
    } catch (BitmapDecodingException e) {
      Log.w(TAG, "Failed to decode!", e);
    }

    if (cropRect != null || rotation != 0 || flip) {
      data = transformByteArray(data, cropRect, rotation, flip);
    }

    int width  = cropRect != null ? (cropRect.right - cropRect.left) : image.getWidth();
    int height = cropRect != null ? (cropRect.bottom - cropRect.top) : image.getHeight();

    if (rotation == 90 || rotation == 270) {
      int swap = width;

      width  = height;
      height = swap;
    }

    return new ImageResult(data, width, height);
  }

  public static boolean isSupported() {
    return Build.VERSION.SDK_INT >= 21 && !CameraXModelBlacklist.isBlacklisted();
  }

  public static int toCameraDirectionInt(int facing) {
    if (facing == CameraSelector.LENS_FACING_FRONT) {
      return Camera.CameraInfo.CAMERA_FACING_FRONT;
    } else {
      return Camera.CameraInfo.CAMERA_FACING_BACK;
    }
  }

  public static int toLensFacing(@CameraSelector.LensFacing int cameraDirectionInt) {
    if (cameraDirectionInt == Camera.CameraInfo.CAMERA_FACING_FRONT) {
      return CameraSelector.LENS_FACING_FRONT;
    } else {
      return CameraSelector.LENS_FACING_BACK;
    }
  }

  public static @NonNull @ImageCapture.CaptureMode int getOptimalCaptureMode() {
    return FastCameraModels.contains(Build.MODEL) ? ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
                                                  : ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY;
  }

  public static int getIdealResolution(int displayWidth, int displayHeight) {
    int maxDisplay = Math.max(displayWidth, displayHeight);
    return Math.max(maxDisplay, 1920);
  }

  @TargetApi(21)
  public static @NonNull Size buildResolutionForRatio(int longDimension, @NonNull Rational ratio, boolean isPortrait) {
    int shortDimension = longDimension * ratio.getDenominator() / ratio.getNumerator();

    if (isPortrait) {
      return new Size(shortDimension, longDimension);
    } else {
      return new Size(longDimension, shortDimension);
    }
  }

  private static byte[] transformByteArray(@NonNull byte[] data, @Nullable Rect cropRect, int rotation, boolean flip) throws IOException {
    Stopwatch stopwatch = new Stopwatch("transform");
    Bitmap in;

    if (cropRect != null) {
      BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(data, 0, data.length, false);
      in = decoder.decodeRegion(cropRect, new BitmapFactory.Options());
      decoder.recycle();
      stopwatch.split("crop");
    } else {
      in = BitmapFactory.decodeByteArray(data, 0, data.length);
    }

    Bitmap out = in;

    if (rotation != 0 || flip) {
      Matrix matrix = new Matrix();
      matrix.postRotate(rotation);

      if (flip) {
        matrix.postScale(-1, 1);
        matrix.postTranslate(in.getWidth(), 0);
      }

      out = Bitmap.createBitmap(in, 0, 0, in.getWidth(), in.getHeight(), matrix, true);
    }

    byte[] transformedData = toJpegBytes(out);
    stopwatch.split("transcode");

    in.recycle();
    out.recycle();

    stopwatch.stop(TAG);

    return transformedData;
  }

  @RequiresApi(21)
  private static boolean shouldCropImage(@NonNull ImageProxy image) {
    Size sourceSize = new Size(image.getWidth(), image.getHeight());
    Size targetSize = new Size(image.getCropRect().width(), image.getCropRect().height());

    return !targetSize.equals(sourceSize);
  }

  private static byte[] toJpegBytes(@NonNull Bitmap bitmap) throws IOException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();

    if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)) {
      throw new IOException("Failed to compress bitmap.");
    }

    return out.toByteArray();
  }

  @RequiresApi(21)
  public static boolean isMixedModeSupported(@NonNull Context context) {
    return getLowestSupportedHardwareLevel(context) != CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
  }

  @RequiresApi(21)
  public static int getLowestSupportedHardwareLevel(@NonNull Context context) {
    @SuppressLint("RestrictedApi") CameraManager cameraManager = CameraManagerCompat.from(context).unwrap();

    try {
      int supported = maxHardwareLevel();

      for (String cameraId : cameraManager.getCameraIdList()) {
        CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId);
        Integer               hwLevel         = characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL);

        if (hwLevel == null || hwLevel == CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
          return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
        }

        supported = smallerHardwareLevel(supported, hwLevel);
      }

      return supported;
    } catch (CameraAccessException e) {
      Log.w(TAG, "Failed to enumerate cameras", e);

      return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
    }
  }

  @RequiresApi(21)
  private static int maxHardwareLevel() {
    if (Build.VERSION.SDK_INT >= 24) return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3;
    else                             return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL;
  }

  @RequiresApi(21)
  private static int smallerHardwareLevel(int levelA, int levelB) {

    int[] hardwareInfoOrdering = getHardwareInfoOrdering();
    for (int hwInfo : hardwareInfoOrdering) {
      if (levelA == hwInfo || levelB == hwInfo) return hwInfo;
    }

    return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY;
  }

  @RequiresApi(21)
  private static int[] getHardwareInfoOrdering() {
    if      (Build.VERSION.SDK_INT >= 28) return CAMERA_HARDWARE_LEVEL_ORDERING_28;
    else if (Build.VERSION.SDK_INT >= 24) return CAMERA_HARDWARE_LEVEL_ORDERING_24;
    else                                  return CAMERA_HARDWARE_LEVEL_ORDERING;
  }

  public static class ImageResult {
    private final byte[] data;
    private final int    width;
    private final int    height;

    public ImageResult(@NonNull byte[] data, int width, int height) {
      this.data   = data;
      this.width  = width;
      this.height = height;
    }

    public byte[] getData() {
      return data;
    }

    public int getWidth() {
      return width;
    }

    public int getHeight() {
      return height;
    }
  }
}
