import assert from 'node:assert';
import { Buffer } from 'node:buffer';
import { readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { PhotonImage as PhotonImageType } from '@silvia-odwyer/photon';
import { getDebug } from '../logger';
import type { Rect, Size } from '../types';
import { ifInBrowser, ifInNode, ifInWorker } from '../utils';
import getPhoton from './get-photon';
import getSharp from './get-sharp';
import { encodedImageInfoOfBuffer } from './info';

const imgDebug = getDebug('img');

/**
 * Saves a Base64-encoded image to a file
 *
 * @param options - An object containing the Base64-encoded image data and the output file path
 * @param options.base64Data - The Base64-encoded image data
 * @param options.outputPath - The path where the image will be saved
 * @throws Error if there is an error during the saving process
 */
export async function saveBase64Image(options: {
  base64Data: string;
  outputPath: string;
}): Promise<void> {
  const { base64Data, outputPath } = options;
  const { body } = parseBase64(base64Data);

  const imageBuffer = Buffer.from(body, 'base64');
  await writeFile(outputPath, imageBuffer);
}

interface ResizeImageBufferOptions {
  sourceSize?: Size;
  preserveOriginalWhenUnchanged: boolean;
  jpegQuality: number;
}

async function resizeImageBuffer(
  inputFormat: string,
  inputData: Buffer,
  targetSize: Size,
  options: ResizeImageBufferOptions,
): Promise<{
  buffer: Buffer;
  format: string;
}> {
  if (typeof inputData === 'string') {
    throw Error('inputData is base64, use resizeImgBase64 instead');
  }

  assert(
    targetSize && targetSize.width > 0 && targetSize.height > 0,
    'newSize must be positive',
  );

  const resizeStartTime = Date.now();
  imgDebug(
    `resizeImg start, target size: ${targetSize.width}x${targetSize.height}`,
  );

  if (ifInNode) {
    const Sharp = await getSharp();
    let originalWidth = options.sourceSize?.width;
    let originalHeight = options.sourceSize?.height;
    if (!originalWidth || !originalHeight) {
      const metadata = await Sharp(inputData).metadata();
      originalWidth = metadata.width;
      originalHeight = metadata.height;
    }

    if (!originalWidth || !originalHeight) {
      throw Error('Undefined width or height from the input image.');
    }

    const dimensionsUnchanged =
      targetSize.width === originalWidth &&
      targetSize.height === originalHeight;
    if (options.preserveOriginalWhenUnchanged && dimensionsUnchanged) {
      return {
        buffer: inputData,
        format: inputFormat,
      };
    }

    const image = Sharp(inputData);
    const resizedBuffer = await (dimensionsUnchanged
      ? image
      : image.resize(targetSize.width, targetSize.height)
    )
      .jpeg({ quality: options.jpegQuality })
      .toBuffer();

    const resizeEndTime = Date.now();
    imgDebug(
      `resizeImg done (Sharp), target size: ${targetSize.width}x${targetSize.height}, cost: ${resizeEndTime - resizeStartTime}ms`,
    );

    return {
      buffer: resizedBuffer,
      // by Sharp.jpeg()
      format: 'jpeg',
    };
  }

  // Browser/worker environment: use Photon.
  const { PhotonImage, SamplingFilter, resize } = await getPhoton();
  const inputBytes = new Uint8Array(inputData);
  const inputImage = PhotonImage.new_from_byteslice(inputBytes);
  const originalWidth = options.sourceSize?.width ?? inputImage.get_width();
  const originalHeight = options.sourceSize?.height ?? inputImage.get_height();

  if (!originalWidth || !originalHeight) {
    inputImage.free();
    throw Error('Undefined width or height from the input image.');
  }

  const dimensionsUnchanged =
    targetSize.width === originalWidth && targetSize.height === originalHeight;
  if (options.preserveOriginalWhenUnchanged && dimensionsUnchanged) {
    inputImage.free();
    return {
      buffer: inputData,
      format: inputFormat,
    };
  }

  let outputImage: PhotonImageType | undefined;
  let resizedBuffer: Buffer;
  try {
    outputImage = dimensionsUnchanged
      ? undefined
      : resize(
          inputImage,
          targetSize.width,
          targetSize.height,
          SamplingFilter.CatmullRom,
        );
    resizedBuffer = Buffer.from(
      (outputImage ?? inputImage).get_bytes_jpeg(options.jpegQuality),
    );
  } finally {
    inputImage.free();
    outputImage?.free();
  }

  const resizeEndTime = Date.now();

  imgDebug(
    `resizeImg done (Photon), target size: ${targetSize.width}x${targetSize.height}, cost: ${resizeEndTime - resizeStartTime}ms`,
  );

  return {
    buffer: resizedBuffer,
    // by Photon.get_bytes_jpeg()
    format: 'jpeg',
  };
}

/**
 * Resizes an image buffer.
 *
 * This API preserves the original bytes and format when the requested
 * dimensions are unchanged. When dimensions change, it returns JPEG. Callers
 * must inspect the returned `format` instead of assuming an output format.
 */
export async function resizeAndConvertImgBuffer(
  inputFormat: string,
  inputData: Buffer,
  newSize: Size,
): Promise<{
  buffer: Buffer;
  /** The actual encoded format of `buffer`, such as `png` or `jpeg`. */
  format: string;
}> {
  return resizeImageBuffer(inputFormat, inputData, newSize, {
    preserveOriginalWhenUnchanged: true,
    jpegQuality: 90,
  });
}

export const normalizeBase64Body = (body: string) => body.replace(/\s/g, '');

/** Convert an image buffer to JPEG without changing its dimensions. */
export async function convertImgBufferToJpeg(
  inputData: Buffer,
  quality = 90,
): Promise<Buffer> {
  if (ifInNode) {
    try {
      const Sharp = await getSharp();
      return await Sharp(inputData).jpeg({ quality }).toBuffer();
    } catch (error) {
      if (!ifInBrowser && !ifInWorker) {
        throw new Error(
          `Failed to convert image to JPEG with Sharp: ${error instanceof Error ? error.message : String(error)}`,
          { cause: error },
        );
      }
      imgDebug('Sharp failed, falling back to Photon:', error);
    }
  }

  const mimeType = detectImageMimeTypeFromBuffer(inputData) ?? 'image/png';
  const photonImage = await photonFromBase64(
    `data:${mimeType};base64,${inputData.toString('base64')}`,
  );
  try {
    return Buffer.from(photonImage.get_bytes_jpeg(quality));
  } finally {
    photonImage.free();
  }
}

const base64ImageDataUrlPattern = /^data:image\/[a-zA-Z0-9.+-]+;base64,/i;
const supportedScreenshotDataUriPattern =
  /^data:image\/(png|jpe?g);base64,([\s\S]*)$/i;
const rawBase64BodyPattern = /^[A-Za-z0-9+/=\s]+$/;

export const inferBase64ImageFormat = (base64Body: string) => {
  if (base64Body.startsWith('iVBORw0KGgo')) {
    return 'png';
  }
  return 'jpeg';
};

function detectImageMimeTypeFromBuffer(buffer: Buffer): string | undefined {
  if (
    buffer.length >= 8 &&
    buffer[0] === 0x89 &&
    buffer[1] === 0x50 &&
    buffer[2] === 0x4e &&
    buffer[3] === 0x47 &&
    buffer[4] === 0x0d &&
    buffer[5] === 0x0a &&
    buffer[6] === 0x1a &&
    buffer[7] === 0x0a
  ) {
    return 'image/png';
  }
  if (
    buffer.length >= 3 &&
    buffer[0] === 0xff &&
    buffer[1] === 0xd8 &&
    buffer[2] === 0xff
  ) {
    return 'image/jpeg';
  }
  if (buffer.length >= 6 && buffer.subarray(0, 3).toString('ascii') === 'GIF') {
    return 'image/gif';
  }
  if (
    buffer.length >= 12 &&
    buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
    buffer.subarray(8, 12).toString('ascii') === 'WEBP'
  ) {
    return 'image/webp';
  }
  if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) {
    return 'image/bmp';
  }
  return undefined;
}

export const createImgBase64ByFormat = (format: string, body: string) => {
  return `data:image/${format};base64,${normalizeBase64Body(body)}`;
};

export interface NormalizeScreenshotBase64Options {
  label?: string;
}

export const normalizeScreenshotBase64 = (
  base64: string,
  options?: NormalizeScreenshotBase64Options,
) => {
  const label = options?.label ?? 'screenshot base64';
  const trimmedBase64 = base64.trim();
  if (!trimmedBase64) {
    throw new Error(`${label} cannot be empty`);
  }

  const dataUriMatch = trimmedBase64.match(supportedScreenshotDataUriPattern);
  if (dataUriMatch) {
    const imageFormat =
      dataUriMatch[1].toLowerCase() === 'jpg'
        ? 'jpeg'
        : dataUriMatch[1].toLowerCase();
    const body = dataUriMatch[2];
    if (!normalizeBase64Body(body)) {
      throw new Error(`${label} cannot be empty`);
    }
    return createImgBase64ByFormat(imageFormat, body);
  }

  if (trimmedBase64.startsWith('data:')) {
    throw new Error(
      `${label} must be a PNG/JPEG data URI or raw PNG base64 string`,
    );
  }

  if (!rawBase64BodyPattern.test(trimmedBase64)) {
    throw new Error(
      `${label} must be a PNG/JPEG data URI or raw PNG base64 string`,
    );
  }

  return createImgBase64ByFormat('png', trimmedBase64);
};

export const normalizeBase64Image = (base64: string) => {
  const trimmedBase64 = base64.trim();
  if (base64ImageDataUrlPattern.test(trimmedBase64)) {
    return trimmedBase64;
  }

  const base64Body = normalizeBase64Body(trimmedBase64);
  assert(base64Body, 'base64 image must include image data');
  return createImgBase64ByFormat(
    inferBase64ImageFormat(base64Body),
    base64Body,
  );
};

export type JpegBase64DataUrl = `data:image/jpeg;base64,${string}`;

export interface ResizeBase64ImageToJpegOptions {
  /**
   * Expected input dimensions in positive integer pixels. When provided, they
   * are checked against the encoded image header before processing.
   */
  sourceSize?: Size;
  /** Exact output dimensions in positive integer pixels. */
  targetSize: Size;
  /** JPEG quality used only when encoding is required. Defaults to 90. */
  jpegQuality?: number;
}

export interface ConstrainBase64ImageToMaxSizeOptions {
  /** Maximum allowed width or height in positive integer pixels. */
  maxSize: number;
  /** JPEG quality used when resizing is required. Defaults to 90. */
  jpegQuality?: number;
}

function assertValidJpegQuality(jpegQuality: number): void {
  if (!Number.isInteger(jpegQuality) || jpegQuality < 1 || jpegQuality > 100) {
    throw new Error(
      `jpegQuality must be an integer between 1 and 100. Received: ${jpegQuality}`,
    );
  }
}

function assertValidImageSize(size: Size, label: string): void {
  if (
    !Number.isInteger(size.width) ||
    !Number.isInteger(size.height) ||
    size.width <= 0 ||
    size.height <= 0
  ) {
    throw new Error(
      `${label} width and height must be positive integers. Received width: ${size.width}, height: ${size.height}`,
    );
  }
}

/**
 * Ensures that a PNG/JPEG Base64 image is represented as JPEG without resizing.
 * Existing JPEG bytes are reused; PNG input is encoded with `jpegQuality`.
 * This function does not read image dimensions.
 *
 * @param inputBase64 - A PNG/JPEG data URL or raw Base64 image body.
 * @param jpegQuality - JPEG quality from 1 to 100 when encoding PNG. Defaults to 90.
 * @returns A JPEG data URL with unchanged dimensions.
 */
export async function convertBase64ImageToJpeg(
  inputBase64: string,
  jpegQuality = 90,
): Promise<JpegBase64DataUrl> {
  assertValidJpegQuality(jpegQuality);
  const { body } = parseBase64(inputBase64);
  const imageBuffer = Buffer.from(body, 'base64');
  const detectedMimeType = detectImageMimeTypeFromBuffer(imageBuffer);
  if (detectedMimeType === 'image/jpeg') {
    return createImgBase64ByFormat('jpeg', body) as JpegBase64DataUrl;
  }
  if (detectedMimeType !== 'image/png') {
    throw new Error(
      `inputBase64 must contain a PNG or JPEG image. Detected: ${detectedMimeType ?? 'unsupported format'}`,
    );
  }

  const jpegBuffer = await convertImgBufferToJpeg(imageBuffer, jpegQuality);
  return createImgBase64ByFormat(
    'jpeg',
    jpegBuffer.toString('base64'),
  ) as JpegBase64DataUrl;
}

/**
 * Resizes a PNG/JPEG Base64 image and ensures that the result is JPEG.
 *
 * An unchanged JPEG is returned without re-encoding. An unchanged PNG is
 * converted to JPEG, while a size change performs resize and JPEG encoding.
 * `jpegQuality` applies only when encoding is required.
 *
 * @param inputBase64 - A PNG/JPEG data URL or raw Base64 image body.
 * @param options - Source dimensions, target dimensions, and JPEG quality.
 * @returns A JPEG data URL with the requested dimensions.
 */
export async function resizeBase64ImageToJpeg(
  inputBase64: string,
  options: ResizeBase64ImageToJpegOptions,
): Promise<JpegBase64DataUrl> {
  const jpegQuality = options.jpegQuality ?? 90;
  assertValidJpegQuality(jpegQuality);
  assertValidImageSize(options.targetSize, 'targetSize');

  const { body, mimeType } = parseBase64(inputBase64);
  const imageBuffer = Buffer.from(body, 'base64');
  const encodedSourceSize = encodedImageInfoOfBuffer(imageBuffer);
  if (options.sourceSize) {
    assertValidImageSize(options.sourceSize, 'sourceSize');
    if (
      options.sourceSize.width !== encodedSourceSize.width ||
      options.sourceSize.height !== encodedSourceSize.height
    ) {
      throw new Error(
        `sourceSize ${options.sourceSize.width}x${options.sourceSize.height} does not match encoded image dimensions ${encodedSourceSize.width}x${encodedSourceSize.height}`,
      );
    }
  }
  const sourceSize = encodedSourceSize;
  const dimensionsUnchanged =
    sourceSize.width === options.targetSize.width &&
    sourceSize.height === options.targetSize.height;
  const detectedMimeType = detectImageMimeTypeFromBuffer(imageBuffer);
  if (dimensionsUnchanged && detectedMimeType?.toLowerCase() === 'image/jpeg') {
    return createImgBase64ByFormat('jpeg', body) as JpegBase64DataUrl;
  }

  const { buffer } = await resizeImageBuffer(
    detectedMimeType?.split('/')[1] ?? mimeType.split('/')[1],
    imageBuffer,
    options.targetSize,
    {
      sourceSize,
      preserveOriginalWhenUnchanged: false,
      jpegQuality,
    },
  );
  return createImgBase64ByFormat(
    'jpeg',
    buffer.toString('base64'),
  ) as JpegBase64DataUrl;
}

/**
 * Constrains a PNG/JPEG Base64 image to a maximum width or height while
 * preserving its aspect ratio.
 *
 * Images already within the bound are returned unchanged, including their
 * original format. Oversized images are parsed once, resized, and encoded as
 * JPEG.
 *
 * @param inputBase64 - A PNG/JPEG data URL or raw Base64 image body.
 * @param options - Maximum dimension and optional JPEG quality.
 * @returns The original image when already bounded, otherwise a JPEG data URL.
 */
export async function constrainBase64ImageToMaxSize(
  inputBase64: string,
  options: ConstrainBase64ImageToMaxSizeOptions,
): Promise<string> {
  if (!Number.isInteger(options.maxSize) || options.maxSize <= 0) {
    throw new Error(
      `maxSize must be a positive integer. Received: ${options.maxSize}`,
    );
  }

  const jpegQuality = options.jpegQuality ?? 90;
  assertValidJpegQuality(jpegQuality);

  const { body, mimeType } = parseBase64(inputBase64);
  const imageBuffer = Buffer.from(body, 'base64');
  const sourceSize = encodedImageInfoOfBuffer(imageBuffer);
  const largestDimension = Math.max(sourceSize.width, sourceSize.height);
  if (largestDimension <= options.maxSize) return inputBase64;

  const scale = options.maxSize / largestDimension;
  const targetSize = {
    width: Math.max(1, Math.round(sourceSize.width * scale)),
    height: Math.max(1, Math.round(sourceSize.height * scale)),
  };
  const detectedMimeType = detectImageMimeTypeFromBuffer(imageBuffer);
  const { buffer } = await resizeImageBuffer(
    detectedMimeType?.split('/')[1] ?? mimeType.split('/')[1],
    imageBuffer,
    targetSize,
    {
      sourceSize,
      preserveOriginalWhenUnchanged: false,
      jpegQuality,
    },
  );

  return createImgBase64ByFormat(
    'jpeg',
    buffer.toString('base64'),
  ) as JpegBase64DataUrl;
}

/**
 * @deprecated Use `resizeBase64ImageToJpeg` when JPEG output is required.
 * This API retains its historical behavior: unchanged dimensions preserve the
 * input format, while resized output is encoded as JPEG.
 */
export async function resizeImgBase64(
  inputBase64: string,
  newSize: {
    width: number;
    height: number;
  },
): Promise<string> {
  const { body, mimeType } = parseBase64(inputBase64);
  const imageBuffer = Buffer.from(body, 'base64');
  const { buffer, format } = await resizeAndConvertImgBuffer(
    mimeType.split('/')[1],
    imageBuffer,
    newSize,
  );
  return createImgBase64ByFormat(format, buffer.toString('base64'));
}

/**
 * Calculates new dimensions for an image while maintaining its aspect ratio.
 *
 * This function is designed to resize an image to fit within a specified maximum width and height
 * while maintaining the original aspect ratio. If the original width or height exceeds the maximum
 * dimensions, the image will be scaled down to fit.
 *
 * @param {number} originalWidth - The original width of the image.
 * @param {number} originalHeight - The original height of the image.
 * @returns {Object} An object containing the new width and height.
 * @throws {Error} Throws an error if the width or height is not a positive number.
 */
export function zoomForGPT4o(originalWidth: number, originalHeight: number) {
  // In low mode, the image is scaled to 512x512 pixels and 85 tokens are used to represent the image.
  // In high mode, the model looks at low-resolution images and then creates detailed crop images, using 170 tokens for each 512x512 pixel tile. In practical applications, it is recommended to control the image size within 2048x768 pixels
  const maxWidth = 2048; // Maximum width
  const maxHeight = 768; // Maximum height
  let newWidth = originalWidth;
  let newHeight = originalHeight;

  // Calculate the aspect ratio
  const aspectRatio = originalWidth / originalHeight;

  // Width adjustment
  if (originalWidth > maxWidth) {
    newWidth = maxWidth;
    newHeight = newWidth / aspectRatio;
  }

  // Adjust height
  if (newHeight > maxHeight) {
    newHeight = maxHeight;
    newWidth = newHeight * aspectRatio;
  }

  return {
    width: Math.round(newWidth),
    height: Math.round(newHeight),
  };
}

export async function photonFromBase64(
  base64: string,
): Promise<PhotonImageType> {
  const { PhotonImage } = await getPhoton();
  const { body } = parseBase64(base64);
  return PhotonImage.new_from_base64(body);
}

// https://help.aliyun.com/zh/model-studio/user-guide/vision/
export async function paddingToMatchBlock(
  image: PhotonImageType,
  blockSize = 28,
): Promise<{
  width: number;
  height: number;
  image: PhotonImageType;
}> {
  const width = image.get_width();
  const height = image.get_height();

  const targetWidth = Math.ceil(width / blockSize) * blockSize;
  const targetHeight = Math.ceil(height / blockSize) * blockSize;

  if (targetWidth === width && targetHeight === height) {
    return { width, height, image };
  }

  const { padding_right, padding_bottom, Rgba } = await getPhoton();

  const rightPadding = targetWidth - width;
  const bottomPadding = targetHeight - height;

  let result = image;
  if (rightPadding > 0) {
    // Rgba object is consumed by padding_right, so create new one for each call
    const white = new Rgba(255, 255, 255, 255);
    result = padding_right(result, rightPadding, white);
  }
  if (bottomPadding > 0) {
    const white = new Rgba(255, 255, 255, 255);
    const previousResult = result;
    result = padding_bottom(previousResult, bottomPadding, white);
    // Free intermediate PhotonImage created by padding_right, but not the original input
    if (previousResult !== image) {
      previousResult.free();
    }
  }

  return { width: targetWidth, height: targetHeight, image: result };
}

export async function paddingToMatchBlockByBase64(
  imageBase64: string,
  blockSize = 28,
): Promise<{
  width: number;
  height: number;
  imageBase64: string;
}> {
  if (ifInNode) {
    const { body } = parseBase64(imageBase64);
    const inputBuffer = Buffer.from(body, 'base64');
    const Sharp = await getSharp();
    const metadata = await Sharp(inputBuffer).metadata();
    const width = metadata.width;
    const height = metadata.height;
    if (!width || !height) {
      throw new Error('Failed to get image dimensions');
    }

    const targetWidth = Math.ceil(width / blockSize) * blockSize;
    const targetHeight = Math.ceil(height / blockSize) * blockSize;
    if (targetWidth === width && targetHeight === height) {
      return { width, height, imageBase64 };
    }

    const output = await Sharp(inputBuffer)
      .extend({
        right: targetWidth - width,
        bottom: targetHeight - height,
        background: { r: 255, g: 255, b: 255, alpha: 1 },
      })
      .jpeg({ quality: 90 })
      .toBuffer();
    return {
      width: targetWidth,
      height: targetHeight,
      imageBase64: createImgBase64ByFormat('jpeg', output.toString('base64')),
    };
  }

  const photonImage = await photonFromBase64(imageBase64);
  try {
    const paddedResult = await paddingToMatchBlock(photonImage, blockSize);
    const result = {
      width: paddedResult.width,
      height: paddedResult.height,
      imageBase64: await photonToBase64(paddedResult.image),
    };
    if (paddedResult.image !== photonImage) {
      paddedResult.image.free();
    }
    return result;
  } finally {
    photonImage.free();
  }
}

export async function cropByRect(
  imageBase64: string,
  rect: Rect,
): Promise<{
  width: number;
  height: number;
  imageBase64: string;
}> {
  if (ifInNode) {
    const { body } = parseBase64(imageBase64);
    const Sharp = await getSharp();
    const left = Math.trunc(rect.left);
    const top = Math.trunc(rect.top);
    const width = Math.trunc(rect.left + rect.width) - left;
    const height = Math.trunc(rect.top + rect.height) - top;
    const output = await Sharp(Buffer.from(body, 'base64'))
      .extract({
        left,
        top,
        width,
        height,
      })
      .jpeg({ quality: 90 })
      .toBuffer();
    return {
      width,
      height,
      imageBase64: createImgBase64ByFormat('jpeg', output.toString('base64')),
    };
  }

  const { crop } = await getPhoton();
  const photonImage = await photonFromBase64(imageBase64);
  const { left, top, width, height } = rect;

  // Photon crop uses coordinates (x1, y1, x2, y2), not (x, y, width, height)
  const cropped = crop(photonImage, left, top, left + width, top + height);
  photonImage.free();

  try {
    return {
      width: cropped.get_width(),
      height: cropped.get_height(),
      imageBase64: await photonToBase64(cropped),
    };
  } finally {
    cropped.free();
  }
}

export async function photonToBase64(
  image: PhotonImageType,
  quality = 90,
): Promise<string> {
  const bytes = image.get_bytes_jpeg(quality);
  const base64Body = Buffer.from(bytes).toString('base64');
  return `data:image/jpeg;base64,${base64Body}`;
}

export const httpImg2Base64 = async (url: string): Promise<string> => {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${url}`);
  }
  const contentType = response.headers.get('content-type');
  if (!contentType) {
    throw new Error(`Failed to fetch image: ${url}`);
  }
  assert(
    contentType.startsWith('image/'),
    `The url ${url} is not a image, because of content-type in header is ${contentType}.`,
  );
  const buffer = Buffer.from(await response.arrayBuffer());
  return `data:${contentType};base64,${buffer.toString('base64')}`;
};

/**
 * Convert image file to base64 string
 * Because this method is synchronous, the npm package `sharp` cannot be used to detect the file type.
 * TODO: convert to webp to reduce base64 size.
 */
export const localImg2Base64 = (
  imgPath: string,
  withoutHeader = false,
): string => {
  const body = readFileSync(imgPath).toString('base64');
  if (withoutHeader) {
    return body;
  }

  // Detect image type by extname.
  const type = path.extname(imgPath).slice(1);
  const finalType = type === 'svg' ? 'svg+xml' : type || 'jpg';

  return `data:image/${finalType};base64,${body}`;
};

/**
 * PreProcess image url to ensure image is accessible to LLM.
 * @param url - The url of the image, it can be a http url or a base64 string or a file path
 * @param convertHttpImage2Base64 - Whether to convert http image to base64, if true, the http image will be converted to base64, otherwise, the http image will be returned as is
 * @returns The base64 string of the image (when convertHttpImage2Base64 is true or url is a file path) or the http image url
 */
export const preProcessImageUrl = async (
  url: string,
  convertHttpImage2Base64: boolean,
) => {
  if (typeof url !== 'string') {
    throw new Error(
      `url must be a string, but got ${url} with type ${typeof url}`,
    );
  }
  if (url.startsWith('data:')) {
    const { mimeType, body } = parseBase64(url);
    return `data:${mimeType};base64,${body}`;
  } else if (url.startsWith('http://') || url.startsWith('https://')) {
    if (!convertHttpImage2Base64) {
      return url;
    }
    return await httpImg2Base64(url);
  } else {
    return await localImg2Base64(url);
  }
};

/**
 * parse base64 string to get mimeType and body
 */
export const parseBase64 = (
  fullBase64String: string,
): {
  mimeType: string;
  body: string;
} => {
  try {
    const separator = ';base64,';
    const index = fullBase64String.indexOf(separator);
    if (index === -1) {
      const body = normalizeBase64Body(fullBase64String);
      const mimeType = detectImageMimeTypeFromBuffer(
        Buffer.from(body, 'base64'),
      );
      if (!mimeType) {
        throw new Error('Invalid base64 string');
      }
      return { mimeType, body };
    }
    return {
      // 5 means 'data:'
      mimeType: fullBase64String.slice(5, index),
      body: normalizeBase64Body(
        fullBase64String.slice(index + separator.length),
      ),
    };
  } catch (e) {
    throw new Error(
      `parseBase64 fail because intput is not a valid base64 string: ${fullBase64String}`,
      {
        cause: e,
      },
    );
  }
};

/**
 * Scales an image by a specified factor using Sharp or Photon
 * @param imageBase64 - Base64 encoded image
 * @param scale - Scale factor (e.g., 2 for 2x, 1.5 for 1.5x)
 * @returns Scaled image with new dimensions
 */
export async function scaleImage(
  imageBase64: string,
  scale: number,
): Promise<{
  width: number;
  height: number;
  imageBase64: string;
}> {
  if (scale <= 0) {
    throw new Error('Scale factor must be positive');
  }

  const { body } = parseBase64(imageBase64);
  const buffer = Buffer.from(body, 'base64');

  const scaleStartTime = Date.now();
  imgDebug(`scaleImage start, scale factor: ${scale}`);

  if (ifInNode) {
    const Sharp = await getSharp();
    const metadata = await Sharp(buffer).metadata();
    const originalWidth = metadata.width || 0;
    const originalHeight = metadata.height || 0;

    if (originalWidth === 0 || originalHeight === 0) {
      throw new Error('Failed to get image dimensions');
    }

    const newWidth = Math.round(originalWidth * scale);
    const newHeight = Math.round(originalHeight * scale);

    const resizedBuffer = await Sharp(buffer)
      .resize(newWidth, newHeight, {
        kernel: 'lanczos3',
        fit: 'fill',
      })
      .jpeg({
        quality: 90,
      })
      .toBuffer();

    const scaleEndTime = Date.now();
    imgDebug(
      `scaleImage done (Sharp): ${originalWidth}x${originalHeight} -> ${newWidth}x${newHeight} (scale=${scale}), cost: ${scaleEndTime - scaleStartTime}ms`,
    );

    const base64 = `data:image/jpeg;base64,${resizedBuffer.toString('base64')}`;

    return {
      width: newWidth,
      height: newHeight,
      imageBase64: base64,
    };
  }

  // Browser/worker environment: use Photon.
  const { PhotonImage, SamplingFilter, resize } = await getPhoton();
  const inputBytes = new Uint8Array(buffer);
  const inputImage = PhotonImage.new_from_byteslice(inputBytes);
  const originalWidth = inputImage.get_width();
  const originalHeight = inputImage.get_height();

  if (!originalWidth || !originalHeight) {
    inputImage.free();
    throw new Error('Failed to get image dimensions');
  }

  const newWidth = Math.round(originalWidth * scale);
  const newHeight = Math.round(originalHeight * scale);

  const outputImage = resize(
    inputImage,
    newWidth,
    newHeight,
    SamplingFilter.CatmullRom,
  );

  const outputBytes = outputImage.get_bytes_jpeg(90);
  const resizedBuffer = Buffer.from(outputBytes);

  // Free memory
  inputImage.free();
  outputImage.free();

  const scaleEndTime = Date.now();
  imgDebug(
    `scaleImage done (Photon): ${originalWidth}x${originalHeight} -> ${newWidth}x${newHeight} (scale=${scale}), cost: ${scaleEndTime - scaleStartTime}ms`,
  );

  const base64 = `data:image/jpeg;base64,${resizedBuffer.toString('base64')}`;

  return {
    width: newWidth,
    height: newHeight,
    imageBase64: base64,
  };
}
