import type { RgbColor } from 'colord';
import { match } from 'ts-pattern';

import type { Mask } from './Mask.js';
import type { DivideOptions } from './compare/divide.js';
import { divide } from './compare/divide.js';
import type { SubtractImageOptions } from './compare/index.js';
import { add, subtract } from './compare/index.js';
import type { MultiplyOptions } from './compare/multiply.js';
import { multiply } from './compare/multiply.js';
import type {
  HistogramOptions,
  MeanOptions,
  MedianOptions,
  VarianceOptions,
} from './compute/index.js';
import { histogram, mean, median, variance } from './compute/index.js';
import { correctColor } from './correctColor/index.js';
import type {
  DrawCircleOnImageOptions,
  DrawLineOnImageOptions,
  DrawMarkerOptions,
  DrawPointsOptions,
  DrawPolygonOnImageOptions,
  DrawPolylineOnImageOptions,
  DrawRectangleOptions,
} from './draw/index.js';
import {
  drawCircleOnImage,
  drawLineOnImage,
  drawMarker,
  drawMarkers,
  drawPoints,
  drawPolygonOnImage,
  drawPolylineOnImage,
  drawRectangle,
} from './draw/index.js';
import type {
  BlurOptions,
  ConvolutionOptions,
  DerivativeFilterOptions,
  FlipOptions,
  GaussianBlurOptions,
  GradientFilterOptions,
  HypotenuseOptions,
  IncreaseContrastOptions,
  InvertOptions,
  LevelOptions,
  MedianFilterOptions,
  PixelateOptions,
} from './filters/index.js';
import {
  blur,
  derivativeFilter,
  directConvolution,
  flip,
  gaussianBlur,
  gradientFilter,
  hypotenuse,
  increaseContrast,
  invert,
  level,
  medianFilter,
  pixelate,
  rawDirectConvolution,
  separableConvolution,
} from './filters/index.js';
import type {
  Point,
  ResizeOptions,
  RotateAngle,
  TransformOptions,
  TransformRotateOptions,
} from './geometry/index.js';
import {
  resize,
  rotate,
  transform,
  transformRotate,
} from './geometry/index.js';
import type { ImageMetadata, Resolution } from './load/load.types.js';
import type {
  BottomHatOptions,
  CannyEdgeOptions,
  CloseOptions,
  DilateOptions,
  ErodeOptions,
  MorphologicalGradientOptions,
  OpenOptions,
  TopHatOptions,
} from './morphology/index.js';
import {
  bottomHat,
  cannyEdgeDetector,
  close,
  dilate,
  erode,
  morphologicalGradient,
  open,
  topHat,
} from './morphology/index.js';
import type {
  ConvertBitDepthOptions,
  ConvertColorOptions,
  CopyToOptions,
  CropAlphaOptions,
  CropOptions,
  CropRectangleOptions,
  ExtractOptions,
  GreyOptions,
  PaintMaskOnImageOptions,
  ThresholdOptions,
} from './operations/index.js';
import {
  convertBitDepth,
  convertColor,
  copyTo,
  crop,
  cropAlpha,
  cropRectangle,
  extract,
  grey,
  paintMaskOnImage,
  split,
  threshold,
} from './operations/index.js';
import type { ImageColorModel } from './utils/constants/colorModels.js';
import { colorModels } from './utils/constants/colorModels.js';
import { getMinMax } from './utils/getMinMax.js';
import {
  validateChannel,
  validateValue,
} from './utils/validators/validators.js';

export type ImageDataArray = Uint8Array | Uint16Array | Uint8ClampedArray;

/**
 * Bit depth of the image (nb of bits that encode each value in the image).
 */
export type BitDepth = 1 | 8 | 16;

export const ImageCoordinates = {
  CENTER: 'center',
  TOP_LEFT: 'top-left',
  TOP_RIGHT: 'top-right',
  BOTTOM_LEFT: 'bottom-left',
  BOTTOM_RIGHT: 'bottom-right',
} as const;
export type ImageCoordinates =
  (typeof ImageCoordinates)[keyof typeof ImageCoordinates];

export interface ImageOptions {
  /**
   * Number of bits per value in each channel.
   * @default `8`.
   */
  bitDepth?: BitDepth;

  /**
   * Typed array holding the image data.
   */
  data?: ImageDataArray;

  /**
   * Color model of the created image.
   * @default `'RGB'`.
   */
  colorModel?: ImageColorModel;

  /**
   * Origin of the image relative to a parent image (top-left corner).
   * @default `{row: 0, column: 0}`
   */
  origin?: Point;
  /**
   * Original resolution decoded from the image.
   */
  resolution?: Resolution;

  meta?: ImageMetadata;
}

export interface CreateFromOptions extends ImageOptions {
  width?: number;
  height?: number;
}

export type ImageValues = [number, number, number, number];

export class Image {
  /**
   * The number of columns of the image.
   */
  public readonly width: number;

  /**
   * The number of rows of the image.
   */
  public readonly height: number;

  /**
   * The total number of pixels in the image (width × height).
   */
  public readonly size: number;

  /**
   * The number of bits per value in each channel.
   */
  public readonly bitDepth: BitDepth;

  /**
   * The color model of the image.
   */
  public readonly colorModel: ImageColorModel;

  /**
   * The number of color channels in the image, excluding the alpha channel.
   * A GREY image has 1 component. An RGB image has 3 components.
   */
  public readonly components: number;

  /**
   * The total number of channels in the image, including the alpha channel.
   */
  public readonly channels: number;

  /**
   * Whether the image has an alpha channel or not.
   */
  public readonly alpha: boolean;

  /**
   * The maximum value that a pixel channel can have.
   */
  public readonly maxValue: number;
  /**
   * Origin of the image relative to a the parent image.
   */
  public readonly origin: Point;

  /**
   * Original image resolution.
   */
  public readonly originalResolution: Resolution | undefined;

  public readonly meta?: ImageMetadata;
  /**
   * Typed array holding the image data.
   */
  private readonly data: ImageDataArray;

  /**
   * Construct a new Image knowing its dimensions.
   * @param width - Image width.
   * @param height - Image height.
   * @param options - Image options.
   */
  public constructor(
    width: number,
    height: number,
    options: ImageOptions = {},
  ) {
    const {
      bitDepth = 8,
      data,
      colorModel = 'RGB',
      origin = { row: 0, column: 0 },
      meta,
      resolution,
    } = options;

    if (width < 1 || !Number.isInteger(width)) {
      throw new RangeError(
        `width must be an integer and at least 1. Received ${width}`,
      );
    }

    if (height < 1 || !Number.isInteger(height)) {
      throw new RangeError(
        `height must be an integer and at least 1. Received ${height}`,
      );
    }

    this.width = width;
    this.height = height;
    this.size = width * height;
    this.bitDepth = bitDepth;
    this.colorModel = colorModel;
    this.origin = origin;
    this.meta = meta;
    this.originalResolution = resolution;
    const colorModelDef = colorModels[colorModel];
    this.components = colorModelDef.components;
    this.alpha = colorModelDef.alpha;
    this.channels = colorModelDef.channels;

    this.maxValue = 2 ** bitDepth - 1;

    if (data === undefined) {
      this.data = createPixelArray(
        this.size,
        this.channels,
        this.alpha,
        this.bitDepth,
        this.maxValue,
      );
    } else {
      if (bitDepth === 8 && data instanceof Uint16Array) {
        throw new RangeError(`bitDepth is ${bitDepth} but data is Uint16Array`);
      } else if (bitDepth === 16 && data instanceof Uint8Array) {
        throw new RangeError(`bitDepth is ${bitDepth} but data is Uint8Array`);
      }
      const expectedLength = this.size * this.channels;
      if (data.length !== expectedLength) {
        throw new RangeError(
          `incorrect data size: ${data.length}. Expected ${expectedLength}`,
        );
      }
      this.data = data;
    }
  }
  /**
   * Returns normalized resolution in pixels per centimeter. If resolution unit is unknown, return null.
   * @returns Object with x and y resolutions in pixel/cm.
   */
  get normalizedResolution() {
    if (!this.originalResolution) {
      return undefined;
    }
    const centimetersPerInch = 2.54;
    const centimetersPerMeter = 100;
    switch (this.originalResolution.unit) {
      case 'inch':
        return {
          x: this.originalResolution.x / centimetersPerInch,
          y: this.originalResolution.y / centimetersPerInch,
        };
      case 'centimeter':
        return {
          x: this.originalResolution.x,
          y: this.originalResolution.y,
        };
      case 'meter':
        return {
          x: this.originalResolution.x / centimetersPerMeter,
          y: this.originalResolution.y / centimetersPerMeter,
        };
      case 'unknown':
        return null;
      default:
        throw new Error('Unknown resolution unit.');
    }
  }
  /**
   * Create a new Image based on the properties of an existing one.
   * @param other - Reference image.
   * @param options - Image options.
   * @returns New image.
   */
  public static createFrom(
    other: Image | Mask,
    options: CreateFromOptions = {},
  ): Image {
    const { width = other.width, height = other.height } = options;
    let bitDepth: BitDepth;
    if (other instanceof Image) {
      bitDepth = other.bitDepth;
    } else {
      bitDepth = 8;
    }
    return new Image(width, height, {
      bitDepth,
      colorModel: other.colorModel,
      origin: other.origin,
      ...options,
    });
  }

  /**
   * Get all the channels of a pixel.
   * @param column - Column index.
   * @param row - Row index.
   * @returns Channels of the pixel.
   */
  public getPixel(column: number, row: number): number[] {
    const result = [];
    const start = (row * this.width + column) * this.channels;
    for (let i = 0; i < this.channels; i++) {
      result.push(this.data[start + i]);
    }
    return result;
  }

  public getColumn(column: number): number[][] {
    const columnValues = [];
    for (let i = 0; i < this.channels; i++) {
      const channelValues = [];
      for (let j = 0; j < this.height; j++) {
        channelValues.push(this.getValue(column, j, i));
      }
      columnValues.push(channelValues);
    }
    return columnValues;
  }

  public getRow(row: number): number[][] {
    const rowValues = [];
    for (let i = 0; i < this.channels; i++) {
      const channelValues = [];
      for (let j = 0; j < this.width; j++) {
        channelValues.push(this.getValue(j, row, i));
      }
      rowValues.push(channelValues);
    }
    return rowValues;
  }
  /**
   * Set all the channels of a pixel.
   * @param column - Column index.
   * @param row - Row index.
   * @param value - New color of the pixel to set.
   */
  public setPixel(column: number, row: number, value: number[]): void {
    const start = (row * this.width + column) * this.channels;
    for (let i = 0; i < this.channels; i++) {
      this.data[start + i] = value[i];
    }
  }

  /**
   * Set all the channels of a pixel if the coordinates are inside the image.
   * @param column - Column index.
   * @param row - Row index.
   * @param value - New color of the pixel to set.
   */
  public setVisiblePixel(column: number, row: number, value: number[]): void {
    if (column >= 0 && column < this.width && row >= 0 && row < this.height) {
      this.setPixel(column, row, value);
    }
  }

  /**
   * Get all the channels of a pixel using its index.
   * @param index - Index of the pixel.
   * @returns Channels of the pixel.
   */
  public getPixelByIndex(index: number): number[] {
    const result = [];
    const start = index * this.channels;
    for (let i = 0; i < this.channels; i++) {
      result.push(this.data[start + i]);
    }
    return result;
  }

  /**
   * Set all the channels of a pixel using its index.
   * @param index - Index of the pixel.
   * @param value - New channel values of the pixel to set.
   */
  public setPixelByIndex(index: number, value: number[]): void {
    const start = index * this.channels;
    for (let i = 0; i < this.channels; i++) {
      this.data[start + i] = value[i];
    }
  }

  /**
   * Get the value of a specific pixel channel. Select pixel using coordinates.
   * @param column - Column index.
   * @param row - Row index.
   * @param channel - Channel index.
   * @returns Value of the specified channel of one pixel.
   */
  public getValue(column: number, row: number, channel: number): number {
    return this.data[(row * this.width + column) * this.channels + channel];
  }

  /**
   * Set the value of a specific pixel channel. Select pixel using coordinates.
   * @param column - Column index.
   * @param row - Row index.
   * @param channel - Channel index.
   * @param value - Value to set.
   */
  public setValue(
    column: number,
    row: number,
    channel: number,
    value: number,
  ): void {
    this.data[(row * this.width + column) * this.channels + channel] = value;
  }

  /**
   * Set the value of a specific pixel channel. Select pixel using coordinates.
   * If the value is out of range it is set to the closest extremety.
   * @param column - Column index.
   * @param row - Row index.
   * @param channel - Channel index.
   * @param value - Value to set.
   */
  public setClampedValue(
    column: number,
    row: number,
    channel: number,
    value: number,
  ): void {
    if (value < 0) value = 0;
    else if (value > this.maxValue) value = this.maxValue;
    this.data[(row * this.width + column) * this.channels + channel] = value;
  }
  /**
   * Get the value of a specific pixel channel. Select pixel using index.
   * @param index - Index of the pixel.
   * @param channel - Channel index.
   * @returns Value of the channel of the pixel.
   */
  public getValueByIndex(index: number, channel: number): number {
    return this.data[index * this.channels + channel];
  }
  /**
   * Set the value of a specific pixel channel. Select pixel using index.
   * @param index - Index of the pixel.
   * @param channel - Channel index.
   * @param value - Value to set.
   */
  public setValueByIndex(index: number, channel: number, value: number): void {
    this.data[index * this.channels + channel] = value;
  }

  /**
   * Set the value of a specific pixel channel. Select pixel using index.
   * If the value is out of range it is set to the closest extremety.
   * @param index - Index of the pixel.
   * @param channel - Channel index.
   * @param value - Value to set.
   */
  public setClampedValueByIndex(
    index: number,
    channel: number,
    value: number,
  ): void {
    if (value < 0) value = 0;
    else if (value > this.maxValue) value = this.maxValue;
    this.data[index * this.channels + channel] = value;
  }

  /**
   * Get the value of a specific pixel channel. Select pixel using a point.
   * @param point - Coordinates of the desired pixel.
   * @param channel - Channel index.
   * @returns Value of the channel of the pixel.
   */
  public getValueByPoint(point: Point, channel: number): number {
    return this.getValue(point.column, point.row, channel);
  }

  /**
   * Set the value of a specific pixel channel. Select pixel using a point.
   * @param point - Coordinates of the pixel.
   * @param channel - Channel index.
   * @param value - Value to set.
   */
  public setValueByPoint(point: Point, channel: number, value: number): void {
    this.setValue(point.column, point.row, channel, value);
  }

  /**
   * Find the min and max values of each channel of the image.
   * @returns An object with arrays of the min and max values.
   */
  public minMax(): { min: number[]; max: number[] } {
    return getMinMax(this);
  }

  /**
   * Return the raw image data.
   * @returns The raw data.
   */
  public getRawImage() {
    return {
      width: this.width,
      height: this.height,
      data: this.data,
      channels: this.channels,
      bitDepth: this.bitDepth,
    };
  }

  public [Symbol.for('nodejs.util.inspect.custom')](): string {
    let dataString;
    if (this.height > 20 || this.width > 20) {
      dataString = '[...]';
    } else {
      dataString = printData(this);
    }
    return `Image {
  width: ${this.width}
  height: ${this.height}
  bitDepth: ${this.bitDepth}
  colorModel: ${this.colorModel}
  channels: ${this.channels}
  data: ${dataString}
}`;
  }

  /**
   * Fill the image with a value or a color.
   * @param value - Value or color.
   * @returns The image instance.
   */
  public fill(value: number | number[]): this {
    if (typeof value === 'number') {
      validateValue(value, this);
      this.data.fill(value);
      return this;
    } else {
      if (value.length !== this.channels) {
        throw new RangeError(
          `the size of value must match the number of channels (${this.channels}). Received ${value.length}`,
        );
      }
      for (const val of value) validateValue(val, this);
      for (let i = 0; i < this.data.length; i += this.channels) {
        for (let j = 0; j <= this.channels; j++) {
          this.data[i + j] = value[j];
        }
      }
      return this;
    }
  }

  /**
   * Fill one channel with a value.
   * @param channel - The channel to fill.
   * @param value - The new value.
   * @returns The image instance.
   */
  public fillChannel(channel: number, value: number): this {
    validateChannel(channel, this);
    validateValue(value, this);
    for (let i = channel; i < this.data.length; i += this.channels) {
      this.data[i] = value;
    }
    return this;
  }

  /**
   * Get one channel of the image as an array.
   * @param channel - The channel to fill.
   * @returns Array with the channel values.
   */
  public getChannel(channel: number): number[] {
    validateChannel(channel, this);
    const result = new Array(this.size);
    for (let i = 0; i < this.size; i++) {
      result[i] = this.data[channel + i * this.channels];
    }
    return result;
  }

  /**
   * Fill the alpha channel with the specified value.
   * @param value - New channel value.
   * @returns The image instance.
   */
  public fillAlpha(value: number): this {
    validateValue(value, this);
    if (!this.alpha) {
      throw new TypeError(
        'fillAlpha can only be called if the image has an alpha channel',
      );
    }
    const alphaIndex = this.channels - 1;
    return this.fillChannel(alphaIndex, value);
  }

  /**
   * Create a copy of this image.
   * @returns The image clone.
   */
  public clone(): Image {
    return Image.createFrom(this, { data: this.data.slice() });
  }

  /**
   * Modify all the values of the image using the given callback.
   * @param cb - Callback that modifies a given value.
   */
  public changeEach(cb: (value: number) => number): void {
    for (let i = 0; i < this.data.length; i++) {
      this.data[i] = cb(this.data[i]);
    }
  }

  /**
   * Get the coordinates of a point in the image. The reference is the top-left corner.
   * @param coordinates - The point for which you want the coordinates.
   * @param round - Whether the coordinates should be rounded. This is useful when you want the center of the image.
   * @returns Coordinates of the point in the format [column, row].
   */
  public getCoordinates(coordinates: ImageCoordinates, round = false): Point {
    return match(coordinates)
      .with('center', () => {
        const centerX = (this.width - 1) / 2;
        const centerY = (this.height - 1) / 2;
        if (round) {
          return { column: Math.round(centerX), row: Math.round(centerY) };
        } else {
          return { column: centerX, row: centerY };
        }
      })
      .with('top-left', () => ({ column: 0, row: 0 }))
      .with('top-right', () => ({ column: this.width - 1, row: 0 }))
      .with('bottom-left', () => ({ column: 0, row: this.height - 1 }))
      .with('bottom-right', () => ({
        column: this.width - 1,
        row: this.height - 1,
      }))
      .exhaustive();
  }

  // COMPARE
  /**
   * Subtract other from an image.
   * @param other - Image to subtract.
   * @param options - Inversion options.
   * @returns The subtracted image.
   */
  public subtract(other: Image, options: SubtractImageOptions = {}): Image {
    return subtract(this, other, options);
  }

  public add(other: Image): Image {
    return add(this, other);
  }
  /**
   *  Multiply image pixels by a constant.
   * @param value - Value which pixels will be multiplied to.
   * @param options - Multiply options.
   * @returns Multiplied image.
   */
  public multiply(value: number, options: MultiplyOptions = {}): Image {
    return multiply(this, value, options);
  }
  /**
   *  Divide image pixels by a constant.
   * @param value - Value which pixels will be divided to.
   * @param options - Divide options.
   * @returns Divided image.
   */
  public divide(value: number, options: DivideOptions = {}): Image {
    return divide(this, value, options);
  }
  // COMPUTE

  public histogram(options?: HistogramOptions): Uint32Array {
    return histogram(this, options);
  }

  /**
   * Compute the mean pixel of an image.
   * @param options - Mean options.
   * @returns The mean pixel.
   */
  public mean(options?: MeanOptions): number[] {
    return mean(this, options);
  }

  /**
   * Compute the median pixel of an image.
   * @param options - Median options.
   * @returns The median pixel.
   */
  public median(options?: MedianOptions): number[] {
    return median(this, options);
  }

  /**
   * Compute the variance of each channel of an image.
   * @param options - Variance options.
   * @returns The variance of the channels of the image.
   */
  public variance(options?: VarianceOptions): number[] {
    return variance(this, options);
  }

  // DRAW

  /**
   * Draw a set of points on an image.
   * @param points - Array of points.
   * @param options - Draw points on Image options.
   * @returns New mask.
   */
  public drawPoints(points: Point[], options: DrawPointsOptions = {}): Image {
    return drawPoints(this, points, options);
  }

  /**
   * Draw a line defined by two points onto an image.
   * @param from - Line starting point.
   * @param to - Line ending point.
   * @param options - Draw Line options.
   * @returns The mask with the line drawing.
   */
  public drawLine(
    from: Point,
    to: Point,
    options: DrawLineOnImageOptions = {},
  ): Image {
    return drawLineOnImage(this, from, to, options);
  }

  /**
   * Draw a rectangle defined by position of the top-left corner, width and height.
   * @param options - Draw rectangle options.
   * @returns The image with the rectangle drawing.
   */
  public drawRectangle(options: DrawRectangleOptions<Image> = {}): Image {
    return drawRectangle(this, options);
  }

  /**
   * Draw a polyline defined by an array of points on an image.
   * @param points - Polyline array of points.
   * @param options - Draw polyline options.
   * @returns The image with the polyline drawing.
   */
  public drawPolyline(
    points: Point[],
    options: DrawPolylineOnImageOptions = {},
  ): Image {
    return drawPolylineOnImage(this, points, options);
  }

  /**
   * Draw a polygon defined by an array of points onto an image.
   * @param points - Polygon vertices.
   * @param options - Draw Line options.
   * @returns The image with the polygon drawing.
   */
  public drawPolygon(
    points: Point[],
    options: DrawPolygonOnImageOptions = {},
  ): Image {
    return drawPolygonOnImage(this, points, options);
  }

  /**
   * Draw a circle defined by center and radius onto an image.
   * @param center - Circle center.
   * @param radius - Circle radius.
   * @param options - Draw circle options.
   * @returns The image with the circle drawing.
   */
  public drawCircle(
    center: Point,
    radius: number,
    options: DrawCircleOnImageOptions = {},
  ): Image {
    return drawCircleOnImage(this, center, radius, options);
  }

  /**
   * Draw a marker on the image.
   * @param point - Marker center point.
   * @param options - Draw marker options.
   * @returns The image with the marker drawing.
   */
  public drawMarker(point: Point, options: DrawMarkerOptions = {}): Image {
    return drawMarker(this, point, options);
  }

  /**
   * Draw markers on the image.
   * @param points - Markers center points.
   * @param options - Draw marker options.
   * @returns The image with the markers drawing.
   */
  public drawMarkers(points: Point[], options: DrawMarkerOptions = {}): Image {
    return drawMarkers(this, points, options);
  }

  // OPERATIONS
  public split(): Image[] {
    return split(this);
  }

  public convertColor(
    colorModel: ImageColorModel,
    options?: ConvertColorOptions,
  ): Image {
    return convertColor(this, colorModel, options);
  }

  public convertBitDepth(
    newDepth: BitDepth,
    options?: ConvertBitDepthOptions,
  ): Image {
    return convertBitDepth(this, newDepth, options);
  }

  public grey(options?: GreyOptions): Image {
    return grey(this, options);
  }

  public copyTo(target: Image, options: CopyToOptions<Image> = {}): Image {
    return copyTo(this, target, options);
  }

  public threshold(options: ThresholdOptions = {}): Mask {
    return threshold(this, options);
  }

  /**
   * Crop the input image to a desired size.
   * @param [options] - Crop options.
   * @returns The new cropped image.
   */
  public crop(options?: CropOptions): Image {
    return crop(this, options);
  }

  /**
   * Crop an oriented rectangle from the image.
   * If the rectangle's length or width are not an integers, its dimension is expanded in both directions such as the length and width are integers.
   * @param points - The points of the rectangle. Points must be circling around the rectangle (clockwise or anti-clockwise)
   * @param options - Crop options, see {@link CropRectangleOptions}
   * @returns The cropped image. The orientation of the image is the one closest to the rectangle passed as input.
   */
  public cropRectangle(points: Point[], options?: CropRectangleOptions) {
    return cropRectangle(this, points, options);
  }

  /**
   * Crops the image based on the alpha channel
   * This removes lines and columns where the alpha channel is lower than a threshold value.
   * @param options - Crop alpha options.
   * @returns The cropped image.
   */
  public cropAlpha(options: CropAlphaOptions = {}): Image {
    return cropAlpha(this, options);
  }

  /**
   * Extract the pixels of an image, as specified in a mask.
   * @param mask - The mask defining which pixels to keep.
   * @param options - Extract options.
   * @returns The extracted image.
   */
  public extract(mask: Mask, options?: ExtractOptions): Image {
    return extract(this, mask, options);
  }

  /**
   * Paint a mask onto an image and the given position and with the given color.
   * @param mask - Mask to paint on the image.
   * @param options - Paint mask options.
   * @returns The painted image.
   */
  public paintMask(mask: Mask, options?: PaintMaskOnImageOptions): Image {
    return paintMaskOnImage(this, mask, options);
  }

  // FILTERS

  public blur(options: BlurOptions): Image {
    return blur(this, options);
  }

  public pixelate(options: PixelateOptions): Image {
    return pixelate(this, options);
  }

  public directConvolution(
    kernel: number[][],
    options?: ConvolutionOptions,
  ): Image {
    return directConvolution(this, kernel, options);
  }

  /**
   * Compute direct convolution of an image and return an array with the raw values.
   * @param kernel - Kernel used for the convolution.
   * @param options - Convolution options.
   * @returns Array with the raw convoluted values.
   */
  public rawDirectConvolution(
    kernel: number[][],
    options?: ConvolutionOptions,
  ): Float64Array {
    return rawDirectConvolution(this, kernel, options);
  }

  public separableConvolution(
    kernelX: number[],
    kernelY: number[],
    options?: ConvolutionOptions,
  ): Image {
    return separableConvolution(this, kernelX, kernelY, options);
  }

  /**
   * Apply a gaussian filter to an image.
   * @param options - Gaussian blur options.
   * @returns The blurred image.
   */
  public gaussianBlur(options: GaussianBlurOptions): Image {
    return gaussianBlur(this, options);
  }
  /**
   * Flip the image.
   * @param options - Flip options.
   * @returns The flipped image.
   */
  public flip(options?: FlipOptions): Image {
    return flip(this, options);
  }

  /**
   *   Invert the colors of the image.
   * @param options - Inversion options.
   * @returns The inverted image.
   */
  public invert(options?: InvertOptions): Image {
    return invert(this, options);
  }

  /**
   * Calculate a new image that is the hypotenuse between the current image and the other.
   * @param other - Other image.
   * @param options - Hypotenuse options.
   * @returns Hypotenuse of the two images.
   */
  public hypotenuse(other: Image, options?: HypotenuseOptions): Image {
    return hypotenuse(this, other, options);
  }

  /**
   * Apply a gradient filter to an image.
   * @param options - Gradient filter options.
   * @returns The gradient image.
   */
  public gradientFilter(options: GradientFilterOptions): Image {
    return gradientFilter(this, options);
  }

  /**
   * Apply a derivative filter to an image.
   * @param options - Derivative filter options.
   * @returns The processed image.
   */
  public derivativeFilter(options?: DerivativeFilterOptions): Image {
    return derivativeFilter(this, options);
  }

  /**
   * Level the image using the optional input and output value. This function allows you to enhance the image's contrast.
   * @param options - Level options.
   * @returns The levelled image.
   */
  public level(options?: LevelOptions): Image {
    return level(this, options);
  }

  /**
   * Increase the contrast of an image by spanning each channel on the range [0, image.maxValue].
   * @param options - Increase contrast options.
   * @returns The enhanced image.
   */
  public increaseContrast(options: IncreaseContrastOptions = {}): Image {
    return increaseContrast(this, options);
  }

  /**
   * Correct the colors in an image using the reference colors.
   * @param measuredColors - Colors from the image, which will be compared to the reference.
   * @param referenceColors - Reference colors.
   * @returns Image with the colors corrected.
   */
  public correctColor(
    measuredColors: RgbColor[],
    referenceColors: RgbColor[],
  ): Image {
    return correctColor(this, measuredColors, referenceColors);
  }

  /**
   * Apply a median filter to the image.
   * @param options - Options to apply for median filter.
   * @returns Image after median filter.
   */
  public medianFilter(options?: MedianFilterOptions) {
    return medianFilter(this, options);
  }

  // GEOMETRY

  public resize(options: ResizeOptions): Image {
    return resize(this, options);
  }

  public rotate(angle: RotateAngle): Image {
    return rotate(this, angle);
  }

  public transform(
    transformMatrix: number[][],
    options?: TransformOptions,
  ): Image {
    return transform(this, transformMatrix, options);
  }

  public transformRotate(
    angle: number,
    options?: TransformRotateOptions,
  ): Image {
    return transformRotate(this, angle, options);
  }

  // MORPHOLOGY
  /**
   * Erode an image.
   * @param options - Erode options.
   * @returns The eroded image.
   */
  public erode(options?: ErodeOptions): Image {
    return erode(this, options);
  }
  /**
   * Dilate an image.
   * @param options - Dilate options.
   * @returns The dilated image.
   */
  public dilate(options?: DilateOptions): Image {
    return dilate(this, options);
  }
  /**
   * Open an image.
   * @param options - Open options.
   * @returns The opened image.
   */
  public open(options?: OpenOptions): Image {
    return open(this, options);
  }

  /**
   * Close an image.
   * @param options - Close options.
   * @returns The closed image.
   */
  public close(options?: CloseOptions): Image {
    return close(this, options);
  }

  /**
   * Top hat of an image.
   * @param options - Top hat options.
   * @returns The top-hatted image.
   */
  public topHat(options?: TopHatOptions): Image {
    return topHat(this, options);
  }

  /**
   * Bottom hat of an image.
   * @param options - Bottom hat options.
   * @returns The bottom-hatted image.
   */
  public bottomHat(options?: BottomHatOptions): Image {
    return bottomHat(this, options);
  }

  /**
   * Apply morphological gradient to an image.
   * @param options - Morphological gradient options.
   * @returns The processed image.
   */
  public morphologicalGradient(options?: MorphologicalGradientOptions): Image {
    return morphologicalGradient(this, options);
  }

  /**
   * Apply Canny edge detection to an image.
   * @param options - Canny edge detection options.
   * @returns The processed image.
   */
  public cannyEdgeDetector(options?: CannyEdgeOptions): Mask {
    return cannyEdgeDetector(this, options);
  }
}

/**
 * Create data array and set alpha channel to max value if applicable.
 * @param size - Number of pixels.
 * @param channels - Number of channels.
 * @param alpha - Specify if there is alpha channel.
 * @param bitDepth - Number of bits per channel.
 * @param maxValue - Maximal acceptable value for the channels.
 * @returns The new pixel array.
 */
function createPixelArray(
  size: number,
  channels: number,
  alpha: boolean,
  bitDepth: BitDepth,
  maxValue: number,
): ImageDataArray {
  const length = channels * size;
  const arr = match(bitDepth)
    .with(8, () => new Uint8Array(length))
    .with(16, () => new Uint16Array(length))
    .otherwise(() => {
      throw new RangeError(`invalid bitDepth: ${bitDepth}`);
    });

  // Alpha channel is 100% by default.
  if (alpha) {
    for (let i = channels - 1; i < length; i += channels) {
      arr[i] = maxValue;
    }
  }

  return arr;
}

/**
 * Returns the image data as a formatted string.
 * @param img - The image instance.
 * @returns Formatted string containing the image data.
 */
function printData(img: Image): string {
  const result = [];
  const padding = img.bitDepth === 8 ? 3 : 5;

  for (let row = 0; row < img.height; row++) {
    const currentRow = [];
    for (let column = 0; column < img.width; column++) {
      for (let channel = 0; channel < img.channels; channel++) {
        currentRow.push(
          String(img.getValue(column, row, channel)).padStart(padding, ' '),
        );
      }
    }
    result.push(`[${currentRow.join(' ')}]`);
  }

  return `{
    [\n     ${result.join('\n     ')}\n    ]
  }`;
}
