/*
This file is part of the Notesnook project (https://notesnook.com/)

Copyright (C) 2023 Streetwriters (Private) Limited

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.

This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
*/

type DeriveDimension = (naturalWidth: number, naturalHeight: number) => number;

interface CompressorOptions {
  resize?: "none" | "cover" | "contain";
  maxWidth: DeriveDimension | number;
  maxHeight: DeriveDimension | number;
  minWidth: DeriveDimension | number;
  minHeight: DeriveDimension | number;
  width: DeriveDimension | number;
  height: DeriveDimension | number;
  mimeType?: "auto" | string;
  quality?: number;
  beforeDraw?: (
    context: CanvasRenderingContext2D,
    canvas: HTMLCanvasElement
  ) => void;
  afterDraw?: (
    context: CanvasRenderingContext2D,
    canvas: HTMLCanvasElement
  ) => void;
}

const DEFAULTS: CompressorOptions = {
  maxWidth: Infinity,
  maxHeight: Infinity,
  minWidth: 0,
  minHeight: 0,
  width: 0,
  height: 0,
  resize: "none",
  quality: 0.8,
  mimeType: "auto",
  beforeDraw: undefined,
  afterDraw: undefined
};

const COMPRESSIBLE_TYPES = [
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/jpg"
];

export function compressImage(file: File, options: Partial<CompressorOptions>) {
  if (!COMPRESSIBLE_TYPES.includes(file.type)) return Promise.resolve(file);
  return new Promise<Blob>((resolve, reject) => {
    const image = new Image();
    image.onload = async () => {
      const compressed = await drawToCanvas({
        file,
        image,
        options: { ...DEFAULTS, ...options },
        naturalHeight: image.naturalHeight,
        naturalWidth: image.naturalWidth
      });
      resolve(compressed.size > file.size ? file : compressed);
      URL.revokeObjectURL(image.src);
    };
    image.onabort = () => {
      reject(new Error("Aborted to load the image."));
    };
    image.onerror = () => {
      reject(new Error("Failed to load the image."));
    };
    image.src = URL.createObjectURL(file);
  });
}

function drawToCanvas({
  file,
  image,
  options,
  naturalWidth,
  naturalHeight,
  rotate = 0,
  scaleX = 1,
  scaleY = 1
}: {
  options: CompressorOptions;
  file: File;
  image: HTMLImageElement;
  naturalWidth: number;
  naturalHeight: number;
  rotate?: number;
  scaleX?: number;
  scaleY?: number;
}) {
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");
  if (!context) throw new Error("Failed to get 2d context.");

  const derive = dimensionDeriver(image);
  const is90DegreesRotated = Math.abs(rotate) % 180 === 90;
  const resizable =
    (options.resize === "contain" || options.resize === "cover") &&
    isPositiveNumber(derive(options.width)) &&
    isPositiveNumber(derive(options.height));
  let maxWidth = Math.max(derive(options.maxWidth), 0) || Infinity;
  let maxHeight = Math.max(derive(options.maxHeight), 0) || Infinity;
  let minWidth = Math.max(derive(options.minWidth), 0) || 0;
  let minHeight = Math.max(derive(options.minHeight), 0) || 0;
  let aspectRatio = naturalWidth / naturalHeight;
  let width = derive(options.width);
  let height = derive(options.height);

  if (is90DegreesRotated) {
    [maxWidth, maxHeight] = [maxHeight, maxWidth];
    [minWidth, minHeight] = [minHeight, minWidth];
    [width, height] = [height, width];
  }

  if (resizable) {
    aspectRatio = width / height;
  }

  ({ width: maxWidth, height: maxHeight } = getAdjustedSizes(
    {
      aspectRatio,
      width: maxWidth,
      height: maxHeight
    },
    "contain"
  ));
  ({ width: minWidth, height: minHeight } = getAdjustedSizes(
    {
      aspectRatio,
      width: minWidth,
      height: minHeight
    },
    "cover"
  ));

  if (resizable) {
    ({ width, height } = getAdjustedSizes(
      {
        aspectRatio,
        width,
        height
      },
      options.resize
    ));
  } else {
    ({ width = naturalWidth, height = naturalHeight } = getAdjustedSizes({
      aspectRatio,
      width,
      height
    }));
  }

  width = Math.floor(
    normalizeDecimalNumber(Math.min(Math.max(width, minWidth), maxWidth))
  );
  height = Math.floor(
    normalizeDecimalNumber(Math.min(Math.max(height, minHeight), maxHeight))
  );

  const destX = -width / 2;
  const destY = -height / 2;
  const destWidth = width;
  const destHeight = height;
  let srcX = 0;
  let srcY = 0;
  let srcWidth = naturalWidth;
  let srcHeight = naturalHeight;

  if (resizable) {
    ({ width: srcWidth, height: srcHeight } = getAdjustedSizes(
      {
        aspectRatio,
        width: naturalWidth,
        height: naturalHeight
      },
      options.resize || "none"
    ));
    srcX = (naturalWidth - srcWidth) / 2;
    srcY = (naturalHeight - srcHeight) / 2;
  }

  if (is90DegreesRotated) {
    [width, height] = [height, width];
  }

  canvas.width = width;
  canvas.height = height;

  if (!options.mimeType || !isImageType(options.mimeType)) {
    options.mimeType = file.type;
  }

  let fillStyle = "transparent";

  const isJPEGImage = options.mimeType === "image/jpeg";
  if (isJPEGImage) {
    fillStyle = "#fff";
  }

  // Override the default fill color (#000, black)
  context.fillStyle = fillStyle;
  context.fillRect(0, 0, width, height);

  if (options.beforeDraw) {
    options.beforeDraw(context, canvas);
  }

  context.save();
  context.translate(width / 2, height / 2);
  context.rotate((rotate * Math.PI) / 180);
  context.scale(scaleX, scaleY);
  if (resizable)
    context.drawImage(
      image,
      srcX,
      srcY,
      srcWidth,
      srcHeight,
      destX,
      destY,
      destWidth,
      destHeight
    );
  else context.drawImage(image, destX, destY, destWidth, destHeight);
  context.restore();

  if (options.afterDraw) {
    options.afterDraw(context, canvas);
  }

  return new Promise<Blob>((resolve) => {
    canvas.toBlob(
      (blob) => resolve(blob || file),
      options.mimeType,
      options.quality
    );
  });
}

const REGEXP_DECIMALS = /\.\d*(?:0|9){12}\d*$/;
/**
 * Normalize decimal number.
 * Check out {@link https://0.30000000000000004.com/}
 */
function normalizeDecimalNumber(value: number, times = 100000000000) {
  return REGEXP_DECIMALS.test(value.toString())
    ? Math.round(value * times) / times
    : value;
}

/**
 * Get the max sizes in a rectangle under the given aspect ratio.
 * @param data - The original sizes.
 * @param type - The adjust type.
 * @returns The result sizes.
 */
export function getAdjustedSizes(
  {
    aspectRatio,
    height,
    width
  }: { aspectRatio: number; height: number; width: number },
  type: "none" | "contain" | "cover" = "none"
) {
  const isValidWidth = isPositiveNumber(width);
  const isValidHeight = isPositiveNumber(height);

  if (isValidWidth && isValidHeight) {
    const adjustedWidth = height * aspectRatio;

    if (
      ((type === "contain" || type === "none") && adjustedWidth > width) ||
      (type === "cover" && adjustedWidth < width)
    ) {
      height = width / aspectRatio;
    } else {
      width = height * aspectRatio;
    }
  } else if (isValidWidth) {
    height = width / aspectRatio;
  } else if (isValidHeight) {
    width = height * aspectRatio;
  }

  return {
    width,
    height
  };
}

const isPositiveNumber = (value: number) => value > 0 && value < Infinity;

function isImageType(mimeType: string): boolean {
  return mimeType.startsWith("image/");
}

function dimensionDeriver(image: HTMLImageElement) {
  return (dimension: DeriveDimension | number) =>
    typeof dimension === "number"
      ? dimension
      : dimension(image.naturalWidth, image.naturalHeight);
}
