<?php

namespace Imagely\NGG\DataStorage;

use Imagely\NGG\DataMappers\Gallery as GalleryMapper;
use Imagely\NGG\DataMappers\Image as ImageMapper;

use Imagely\NGG\DataTypes\{ Gallery, Image, LegacyThumbnail };
use Imagely\NGG\Display\I18N;
use Imagely\NGG\IGW\EventPublisher;
use Imagely\NGG\Settings\Settings;
use Imagely\NGG\Util\{ Filesystem, Router, Security };

class Manager {

	public static $instance = null;

	protected $gallery_mapper;
	protected $image_mapper;

	/** @deprecated */
	public $_image_mapper;

	/** @deprecated */
	public $object;

	protected static $gallery_abspath_cache = [];
	protected static $image_abspath_cache   = [];
	protected static $image_url_cache       = [];

	public function __construct() {
		$this->gallery_mapper = GalleryMapper::get_instance();
		$this->image_mapper   = ImageMapper::get_instance();

		/**
		 * @TODO Remove in a later release - this fixes an issue with Imagify at the time of 3.50's release.
		 */
		$this->object        = $this;
		$this->_image_mapper = $this->image_mapper;
	}

	/**
	 * @return Manager
	 */
	static function get_instance() {
		if ( ! isset( self::$instance ) ) {
			self::$instance = new Manager();
		}
		return self::$instance;
	}

	public function has_method( $name ) {
		return method_exists( $this, $name );
	}

	/**
	 * @TODO: Remove this 'magic' method so that our code is always understandable without needing deep context
	 * @param string $method
	 * @param array  $args
	 * @return mixed
	 * @throws \Exception
	 */
	public function __call( $method, $args ) {
		if ( preg_match( '/^get_(\w+)_(abspath|url|dimensions|html|size_params)$/', $method, $match ) ) {
			if ( isset( $match[1] ) && isset( $match[2] ) && ! method_exists( $this, $method ) ) {
				$method = 'get_image_' . $match[2];
				$args[] = $match[1];
				return $this->$method( $args );
			}
		}

		return $this->$method( $args );
	}

	/**
	 * Remove after Pro attains level 1 compatibility with the POPE removal
	 */
	public function get_wrapped_instance() {
		return $this;
	}

	/**
	 * Remove after Pro attains level 1 compatibility with the POPE removal
	 */
	public function add_mixin( $unused = '' ) {}

	/**
	 * Backs up an image file
	 *
	 * @param int|object $image
	 * @param bool       $save
	 * @return bool
	 */
	public function backup_image( $image, $save = true ) {
		$retval     = false;
		$image_path = $this->get_image_abspath( $image );

		if ( $image_path && @file_exists( $image_path ) ) {
			$retval = copy( $image_path, $this->get_backup_abspath( $image ) );

			// Store the dimensions of the image.
			if ( function_exists( 'getimagesize' ) ) {
				$mapper = ImageMapper::get_instance();
				if ( ! is_object( $image ) ) {
					$image = $mapper->find( $image );
				}
				if ( $image ) {
					if ( empty( $image->meta_data ) || ! is_array( $image->meta_data ) ) {
						$image->meta_data = [];
					}
					$dimensions                 = getimagesize( $image_path );
					$image->meta_data['backup'] = [
						'filename'  => basename( $image_path ),
						'width'     => $dimensions[0],
						'height'    => $dimensions[1],
						'generated' => microtime(),
					];
					if ( $save ) {
						$mapper->save( $image );
					}
				}
			}
		}

		return $retval;
	}

	/**
	 * @param string $zipfile
	 * @param string $dest_path
	 * @return bool false on failure
	 */
	public function extract_zip( $zipfile, $dest_path ) {
		wp_mkdir_p( $dest_path );

		if ( class_exists( 'ZipArchive', false ) && apply_filters( 'unzip_file_use_ziparchive', true ) ) {
			$zipObj = new \ZipArchive();
			if ( $zipObj->open( $zipfile ) === false ) {
				return false;
			}

			for ( $i = 0; $i < $zipObj->numFiles; $i++ ) {
				$filename = $zipObj->getNameIndex( $i );
				if ( ! $this->is_allowed_image_extension( $filename ) ) {
					continue;
				}
				$zipObj->extractTo( $dest_path, [ $zipObj->getNameIndex( $i ) ] );
			}
		} else {
			require_once ABSPATH . 'wp-admin/includes/class-pclzip.php';
			$zipObj           = new \PclZip( $zipfile );
			$zipContent       = $zipObj->listContent();
			$indexesToExtract = [];

			foreach ( $zipContent as $zipItem ) {
				if ( $zipItem['folder'] ) {
					continue;
				}
				if ( ! $this->is_allowed_image_extension( $zipItem['stored_filename'] ) ) {
					continue;
				}
				$indexesToExtract[] = $zipItem['index'];
			}

			if ( ! $zipObj->extractByIndex( implode( ',', $indexesToExtract ), $dest_path ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Gets the id of a gallery, regardless of whether an integer or object was passed as an argument
	 *
	 * @param mixed $gallery_obj_or_id
	 * @return null|int
	 */
	public function get_gallery_id( $gallery_obj_or_id ) {
		$retval      = null;
		$gallery_key = $this->gallery_mapper->get_primary_key_column();

		if ( is_object( $gallery_obj_or_id ) ) {
			if ( isset( $gallery_obj_or_id->$gallery_key ) ) {
				$retval = $gallery_obj_or_id->$gallery_key;
			}
		} elseif ( is_numeric( $gallery_obj_or_id ) ) {
			$retval = $gallery_obj_or_id;
		}

		return $retval;
	}

	/**
	 * Empties the gallery cache directory of content
	 *
	 * @param object $gallery
	 */
	public function flush_cache( $gallery ) {
		$fs = Filesystem::get_instance();
		$fs->flush_directory( $this->get_cache_abspath( $gallery ) );
	}

	/**
	 * Returns an array of dimensional properties (width, height, real_width, real_height) of a resulting clone image if and when generated
	 *
	 * @param object|int $image Image ID or an image object
	 * @param string     $size
	 * @param array      $params
	 * @param bool       $skip_defaults
	 * @return bool|array
	 */
	public function calculate_image_size_dimensions( $image, $size, $params = null, $skip_defaults = false ) {
		$retval = false;

		// Get the image entity.
		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		// Ensure we have a valid image.
		if ( $image ) {
			$params = $this->get_image_size_params( $image, $size, $params, $skip_defaults );

			// Get the image filename.
			$image_path = $this->get_image_abspath( $image, 'full', true );
			$clone_path = $this->get_image_abspath( $image, $size );

			$retval = $this->calculate_image_clone_dimensions( $image_path, $clone_path, $params );
		}

		return $retval;
	}

	/**
	 * Generates a "clone" for an existing image, the clone can be altered using the $params array
	 *
	 * @param string $image_path
	 * @param string $clone_path
	 * @param array  $params
	 * @return null|object
	 */
	public function generate_image_clone( $image_path, $clone_path, $params ) {
		$crop       = isset( $params['crop'] ) ? $params['crop'] : null;
		$watermark  = isset( $params['watermark'] ) ? $params['watermark'] : null;
		$reflection = isset( $params['reflection'] ) ? $params['reflection'] : null;
		$rotation   = isset( $params['rotation'] ) ? $params['rotation'] : null;
		$flip       = isset( $params['flip'] ) ? $params['flip'] : '';
		$destpath   = null;
		$thumbnail  = null;

		$result = $this->calculate_image_clone_result( $image_path, $clone_path, $params );

		// XXX this should maybe be removed and extra settings go into $params?
		$settings = apply_filters( 'ngg_settings_during_image_generation', Settings::get_instance()->to_array() );

		// Ensure we have a valid image.
		if ( $image_path && @file_exists( $image_path ) && null != $result && ! isset( $result['error'] ) ) {
			$image_dir    = dirname( $image_path );
			$clone_path   = $result['clone_path'];
			$clone_dir    = $result['clone_directory'];
			$clone_format = $result['clone_format'];
			$format_list  = $this->get_image_format_list();

			// Ensure target directory exists, but only create 1 subdirectory.
			if ( ! @file_exists( $clone_dir ) ) {
				if ( strtolower( realpath( $image_dir ) ) != strtolower( realpath( $clone_dir ) ) ) {
					if ( strtolower( realpath( $image_dir ) ) == strtolower( realpath( dirname( $clone_dir ) ) ) ) {
						wp_mkdir_p( $clone_dir );
					}
				}
			}

			$method  = $result['method'];
			$width   = $result['width'];
			$height  = $result['height'];
			$quality = $result['quality'];

			if ( null === $quality ) {
				$quality = 100;
			}

			// phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText
			if ( $method == 'wordpress' ) {
				$original = wp_get_image_editor( $image_path );
				$destpath = $clone_path;
				if ( ! is_wp_error( $original ) ) {
					$original->resize( $width, $height, $crop );
					$original->set_quality( $quality );
					$original->save( $clone_path );
				}
			} elseif ( $method == 'nextgen' ) {
				$destpath  = $clone_path;
				$thumbnail = new LegacyThumbnail( $image_path, true );
				if ( ! $thumbnail->error ) {
					if ( $crop ) {
						$crop_area   = $result['crop_area'];
						$crop_x      = $crop_area['x'];
						$crop_y      = $crop_area['y'];
						$crop_width  = $crop_area['width'];
						$crop_height = $crop_area['height'];

						$thumbnail->crop( $crop_x, $crop_y, $crop_width, $crop_height );
					}

					$thumbnail->resize( $width, $height );
				} else {
					$thumbnail = null;
				}
			}

			// We successfully generated the thumbnail.
			if ( is_string( $destpath ) && ( @file_exists( $destpath ) || $thumbnail != null ) ) {
				if ( $clone_format != null ) {
					if ( isset( $format_list[ $clone_format ] ) ) {
						$clone_format_extension     = $format_list[ $clone_format ];
						$clone_format_extension_str = null;

						if ( $clone_format_extension != null ) {
							$clone_format_extension_str = '.' . $clone_format_extension;
						}

						$destpath_info      = I18N::mb_pathinfo( $destpath );
						$destpath_extension = $destpath_info['extension'];

						if ( strtolower( $destpath_extension ) != strtolower( $clone_format_extension ) ) {
							$destpath_dir      = $destpath_info['dirname'];
							$destpath_basename = $destpath_info['filename'];
							$destpath_new      = $destpath_dir . DIRECTORY_SEPARATOR . $destpath_basename . $clone_format_extension_str;

							if ( ( @file_exists( $destpath ) && rename( $destpath, $destpath_new ) ) || $thumbnail != null ) {
								$destpath = $destpath_new;
							}
						}
					}
				}

				if ( is_null( $thumbnail ) ) {
					$thumbnail = new LegacyThumbnail( $destpath, true );

					if ( $thumbnail->error ) {
						$thumbnail = null;

						return null;
					}
				} else {
					$thumbnail->fileName = $destpath;
				}

				// This is quite odd, when watermark equals int(0) it seems all statements below ($watermark == 'image') and ($watermark == 'text') both evaluate as true
				// so we set it at null if it evaluates to any null-like value.
				if ( null === $watermark ) {
					$watermark = null;
				}

				if ( 1 == $watermark || true === $watermark ) {
					$watermark_setting_keys = [
						'wmFont',
						'wmType',
						'wmPos',
						'wmXpos',
						'wmYpos',
						'wmPath',
						'wmText',
						'wmOpaque',
						'wmFont',
						'wmSize',
						'wmColor',
					];
					foreach ( $watermark_setting_keys as $watermark_key ) {
						if ( ! isset( $params[ $watermark_key ] ) ) {
							$params[ $watermark_key ] = $settings[ $watermark_key ];
						}
					}

					if ( in_array( strval( $params['wmType'] ), [ 'image', 'text' ], true ) ) {
						$watermark = $params['wmType'];
					} else {
						$watermark = 'text';
					}
				}

				$watermark = strval( $watermark );

				if ( $watermark == 'image' ) {
					$thumbnail->watermarkImgPath = $params['wmPath'];
					$thumbnail->watermarkImage( $params['wmPos'], $params['wmXpos'], $params['wmYpos'] );
				} elseif ( $watermark == 'text' ) {
					$thumbnail->watermarkText = $params['wmText'];
					$thumbnail->watermarkCreateText( $params['wmColor'], $params['wmFont'], $params['wmSize'], $params['wmOpaque'] );
					$thumbnail->watermarkImage( $params['wmPos'], $params['wmXpos'], $params['wmYpos'] );
				}

				if ( $rotation && in_array( abs( $rotation ), [ 90, 180, 270 ], true ) ) {
					$thumbnail->rotateImageAngle( $rotation );
				}

				$flip = strtolower( $flip );

				if ( $flip && in_array( $flip, [ 'h', 'v', 'hv' ], true ) ) {
					$flip_h = in_array( $flip, [ 'h', 'hv' ], true );
					$flip_v = in_array( $flip, [ 'v', 'hv' ], true );

					$thumbnail->flipImage( $flip_h, $flip_v );
				}

				if ( $reflection ) {
					$thumbnail->createReflection( 40, 40, 50, false, '#a4a4a4' );
				}

				// Force format.
				if ( $clone_format != null && isset( $format_list[ $clone_format ] ) ) {
					$thumbnail->format = strtoupper( $format_list[ $clone_format ] );
				}

				$thumbnail = apply_filters( 'ngg_before_save_thumbnail', $thumbnail );

				// Always retrieve metadata from the backup when possible.
				$backup_path  = $image_path . '_backup';
				$exif_abspath = @file_exists( $backup_path ) ? $backup_path : $image_path;

				$exif_iptc = EXIFWriter::read_metadata( $exif_abspath );

				$thumbnail->save( $destpath, $quality );

				@EXIFWriter::write_metadata( $destpath, $exif_iptc );
			}
		}

		return $thumbnail;
	}

	/**
	 * Returns an array of dimensional properties (width, height, real_width, real_height) of a resulting clone image if and when generated
	 *
	 * @param string $image_path
	 * @param string $clone_path
	 * @param array  $params
	 * @return null|array
	 */
	public function calculate_image_clone_dimensions( $image_path, $clone_path, $params ) {
		$retval = null;
		$result = $this->calculate_image_clone_result( $image_path, $clone_path, $params );

		if ( $result != null ) {
			$retval = [
				'width'       => $result['width'],
				'height'      => $result['height'],
				'real_width'  => $result['real_width'],
				'real_height' => $result['real_height'],
			];
		}

		return $retval;
	}

	/**
	 * Returns an array of properties of a resulting clone image if and when generated
	 *
	 * @param string $image_path
	 * @param string $clone_path
	 * @param array  $params
	 * @return null|array
	 */
	public function calculate_image_clone_result( $image_path, $clone_path, $params ) {
		$width      = isset( $params['width'] ) ? $params['width'] : null;
		$height     = isset( $params['height'] ) ? $params['height'] : null;
		$quality    = isset( $params['quality'] ) ? $params['quality'] : null;
		$type       = isset( $params['type'] ) ? $params['type'] : null;
		$crop       = isset( $params['crop'] ) ? $params['crop'] : null;
		$watermark  = isset( $params['watermark'] ) ? $params['watermark'] : null;
		$rotation   = isset( $params['rotation'] ) ? $params['rotation'] : null;
		$reflection = isset( $params['reflection'] ) ? $params['reflection'] : null;
		$crop_frame = isset( $params['crop_frame'] ) ? $params['crop_frame'] : null;
		$result     = null;

		// Ensure we have a valid image.
		if ( $image_path && @file_exists( $image_path ) ) {
			// Ensure target directory exists, but only create 1 subdirectory.
			$image_dir           = dirname( $image_path );
			$clone_dir           = dirname( $clone_path );
			$image_extension     = I18N::mb_pathinfo( $image_path, PATHINFO_EXTENSION );
			$image_extension_str = null;
			$clone_extension     = I18N::mb_pathinfo( $clone_path, PATHINFO_EXTENSION );
			$clone_extension_str = null;

			if ( $image_extension != null ) {
				$image_extension_str = '.' . $image_extension;
			}

			if ( $clone_extension != null ) {
				$clone_extension_str = '.' . $clone_extension;
			}

			$image_basename = I18N::mb_basename( $image_path );
			$clone_basename = I18N::mb_basename( $clone_path );
			// We use a default suffix as passing in null as the suffix will make WordPress use a default.
			$clone_suffix = null;
			$format_list  = $this->get_image_format_list();
			$clone_format = null; // format is determined below and based on $type otherwise left to null.

			// suffix is only used to reconstruct paths for image_resize function.
			if ( strpos( $clone_basename, $image_basename ) === 0 ) {
				$clone_suffix = substr( $clone_basename, strlen( $image_basename ) );
			}

			if ( $clone_suffix != null && $clone_suffix[0] == '-' ) {
				// WordPress adds '-' on its own.
				$clone_suffix = substr( $clone_suffix, 1 );
			}

			// Get original image dimensions.
			$dimensions = getimagesize( $image_path );

			if ( $width == null && $height == null ) {
				if ( $dimensions != null ) {

					if ( $width == null ) {
						$width = $dimensions[0];
					}

					if ( $height == null ) {
						$height = $dimensions[1];
					}
				} else {
					// XXX Don't think there's any other option here but to fail miserably...use some hard-coded defaults maybe?
					return null;
				}
			}

			if ( $dimensions != null ) {
				$dimensions_ratio = $dimensions[0] / $dimensions[1];

				if ( $width == null ) {
					$width = (int) round( $height * $dimensions_ratio );

					if ( $width == ( $dimensions[0] - 1 ) ) {
						$width = $dimensions[0];
					}
				} elseif ( $height == null ) {
					$height = (int) round( $width / $dimensions_ratio );

					if ( $height == ( $dimensions[1] - 1 ) ) {
						$height = $dimensions[1];
					}
				}

				if ( $width > $dimensions[0] ) {
					$width = $dimensions[0];
				}

				if ( $height > $dimensions[1] ) {
					$height = $dimensions[1];
				}

				$image_format = $dimensions[2];

				if ( $type != null ) {
					if ( is_string( $type ) ) {
						$type = strtolower( $type );

						// Indexes in the $format_list array correspond to IMAGETYPE_XXX values appropriately.
						if ( ( $index = array_search( $type, $format_list ) ) !== false ) {
							$type = $index;

							if ( $type != $image_format ) {
								// Note: this only changes the FORMAT of the image but not the extension.
								$clone_format = $type;
							}
						}
					}
				}
			}

			if ( $width == null || $height == null ) {
				// Something went wrong...
				return null;
			}

			// We now need to estimate the 'quality' or level of compression applied to the original JPEG: *IF* the
			// original image has a quality lower than the $quality parameter we will end up generating a new image
			// that is MUCH larger than the original. 'Quality' as an EXIF or IPTC property is quite unreliable
			// and not all software honors or treats it the same way. This calculation is simple: just compare the size
			// that our image could become to what it currently is. '3' is important here as JPEG uses 3 bytes per pixel.
			//
			// First we attempt to use ImageMagick if we can; it has a more robust method of calculation.
			// Note: Some hosting environments have ImageMagick installed but without JPEG support,
			// which causes "NoDecodeDelegateForThisImageFormat" errors. This code handles that gracefully.
			if ( ! empty( $dimensions['mime'] ) && $dimensions['mime'] == 'image/jpeg' ) {
				$possible_quality = null;
				$try_image_magick = true;

				if ( ( defined( 'NGG_DISABLE_IMAGICK' ) && NGG_DISABLE_IMAGICK )
				|| ( function_exists( 'is_wpe' ) && ( $dimensions[0] >= 8000 || $dimensions[1] >= 8000 ) )
				|| ! apply_filters( 'ngg_use_imagick_for_quality', true ) ) {
					$try_image_magick = false;
				}

				if ( $try_image_magick && extension_loaded( 'imagick' ) && class_exists( 'Imagick' ) ) {
					// Check if ImageMagick supports JPEG before attempting to use it
					if ( $this->imagick_supports_jpeg() ) {
						try {
							$img = new \Imagick( $image_path );
							if ( method_exists( $img, 'getImageCompressionQuality' ) ) {
								$possible_quality = $img->getImageCompressionQuality();
							}
							// Clean up the Imagick object
							if ( isset( $img ) ) {
								$img->clear();
								$img->destroy();
							}
						} catch ( \ImagickException $e ) {
							// ImageMagick doesn't support this image format, fall back to GD calculation
							error_log( 'NextGEN Gallery: ImageMagick JPEG support not available, falling back to GD: ' . $e->getMessage() );
							$try_image_magick = false;
						} catch ( \Exception $e ) {
							// Any other ImageMagick error, fall back to GD calculation
							error_log( 'NextGEN Gallery: ImageMagick error, falling back to GD: ' . $e->getMessage() );
							$try_image_magick = false;
						}
					} else {
						// ImageMagick doesn't support JPEG, skip to GD calculation
						$try_image_magick = false;
					}
				}

				// ImageMagick wasn't available or quality is zero so we guess it from the dimensions and filesize.
				if ( null === $possible_quality || 0 === $possible_quality ) {
					$filesize         = filesize( $image_path );
					$possible_quality = ( 101 - ( ( $width * $height ) * 3 ) / $filesize );
				}

				if ( $possible_quality !== null && $possible_quality < $quality ) {
					$quality = $possible_quality;
				}
			}

			$result['clone_path']      = $clone_path;
			$result['clone_directory'] = $clone_dir;
			$result['clone_suffix']    = $clone_suffix;
			$result['clone_format']    = $clone_format;
			$result['base_width']      = $dimensions[0];
			$result['base_height']     = $dimensions[1];

			// image_resize() has limitations:
			// - no easy crop frame support
			// - fails if the dimensions are unchanged
			// - doesn't support filename prefix, only suffix so names like thumbs_original_name.jpg for $clone_path are not supported
			// also suffix cannot be null as that will make WordPress use a default suffix...we could use an object that returns empty string from __toString() but for now just fallback to ngg generator.
			if ( false ) {
				// phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText
				$result['method'] = 'wordpress';

				$new_dims = image_resize_dimensions( $dimensions[0], $dimensions[1], $width, $height, $crop );

				if ( $new_dims ) {
					list($dst_x, $dst_y, $src_x, $src_y, $dst_w, $dst_h, $src_w, $src_h) = $new_dims;

					$width  = $dst_w;
					$height = $dst_h;
				} else {
					$result['error'] = new \WP_Error( 'error_getting_dimensions', __( 'Could not calculate resized image dimensions', 'nggallery' ) );
				}
			} else {
				$result['method'] = 'nextgen';
				$original_width   = $dimensions[0];
				$original_height  = $dimensions[1];
				$aspect_ratio     = $width / $height;

				$orig_ratio_x = $original_width / $width;
				$orig_ratio_y = $original_height / $height;

				if ( $crop ) {
					$algo = 'shrink'; // either 'adapt' or 'shrink'.

					if ( $crop_frame != null ) {
						$crop_x            = (int) round( $crop_frame['x'] );
						$crop_y            = (int) round( $crop_frame['y'] );
						$crop_width        = (int) round( $crop_frame['width'] );
						$crop_height       = (int) round( $crop_frame['height'] );
						$crop_final_width  = (int) round( $crop_frame['final_width'] );
						$crop_final_height = (int) round( $crop_frame['final_height'] );

						$crop_width_orig  = $crop_width;
						$crop_height_orig = $crop_height;

						$crop_factor_x = $crop_width / $crop_final_width;
						$crop_factor_y = $crop_height / $crop_final_height;

						$crop_ratio_x = $crop_width / $width;
						$crop_ratio_y = $crop_height / $height;

						if ( $algo == 'adapt' ) {
							// XXX not sure about this...don't use for now
							// $crop_width = (int) round($width * $crop_factor_x);
							// $crop_height = (int) round($height * $crop_factor_y);.
						} elseif ( $algo == 'shrink' ) {
							if ( $crop_ratio_x < $crop_ratio_y ) {
								$crop_width  = max( $crop_width, $width );
								$crop_height = (int) round( $crop_width / $aspect_ratio );
							} else {
								$crop_height = max( $crop_height, $height );
								$crop_width  = (int) round( $crop_height * $aspect_ratio );
							}

							if ( $crop_width == ( $crop_width_orig - 1 ) ) {
								$crop_width = $crop_width_orig;
							}

							if ( $crop_height == ( $crop_height_orig - 1 ) ) {
								$crop_height = $crop_height_orig;
							}
						}

						$crop_diff_x = (int) round( ( $crop_width_orig - $crop_width ) / 2 );
						$crop_diff_y = (int) round( ( $crop_height_orig - $crop_height ) / 2 );

						$crop_x += $crop_diff_x;
						$crop_y += $crop_diff_y;

						$crop_max_x = ( $crop_x + $crop_width );
						$crop_max_y = ( $crop_y + $crop_height );

						// Check if we're overflowing borders.
						//
						if ( $crop_x < 0 ) {
							$crop_x = 0;
						} elseif ( $crop_max_x > $original_width ) {
							$crop_x -= ( $crop_max_x - $original_width );
						}

						if ( $crop_y < 0 ) {
							$crop_y = 0;
						} elseif ( $crop_max_y > $original_height ) {
							$crop_y -= ( $crop_max_y - $original_height );
						}
					} else {
						if ( $orig_ratio_x < $orig_ratio_y ) {
							$crop_width  = $original_width;
							$crop_height = (int) round( $height * $orig_ratio_x );

						} else {
							$crop_height = $original_height;
							$crop_width  = (int) round( $width * $orig_ratio_y );
						}

						if ( $crop_width == ( $width - 1 ) ) {
							$crop_width = $width;
						}

						if ( $crop_height == ( $height - 1 ) ) {
							$crop_height = $height;
						}

						$crop_x = (int) round( ( $original_width - $crop_width ) / 2 );
						$crop_y = (int) round( ( $original_height - $crop_height ) / 2 );
					}

					$result['crop_area'] = [
						'x'      => $crop_x,
						'y'      => $crop_y,
						'width'  => $crop_width,
						'height' => $crop_height,
					];
				} else {
					// Just constraint dimensions to ensure there's no stretching or deformations.
					list($width, $height) = wp_constrain_dimensions( $original_width, $original_height, $width, $height );
				}
			}

			$result['width']   = $width;
			$result['height']  = $height;
			$result['quality'] = $quality;

			$real_width  = $width;
			$real_height = $height;

			if ( $rotation && in_array( abs( $rotation ), [ 90, 270 ], true ) ) {
				$real_width  = $height;
				$real_height = $width;
			}

			if ( $reflection ) {
				// default for nextgen was 40%, this is used in generate_image_clone as well.
				$reflection_amount = 40;
				// Note, round() would probably be best here but using the same code that LegacyThumbnail uses for compatibility.
				$reflection_height = intval( $real_height * ( $reflection_amount / 100 ) );
				$real_height       = $real_height + $reflection_height;
			}

			$result['real_width']  = $real_width;
			$result['real_height'] = $real_height;
		}

		return $result;
	}

	public function generate_resized_image( $image, $save = true ) {
		$image_abspath = $this->get_image_abspath( $image, 'full' );

		$generated = $this->generate_image_clone(
			$image_abspath,
			$image_abspath,
			$this->get_image_size_params( $image, 'full' )
		);

		if ( $generated && $save ) {
			$this->update_image_dimension_metadata( $image, $image_abspath );
		}

		if ( $generated ) {
			$generated->destruct();
		}
	}

	public function update_image_dimension_metadata( $image, $image_abspath ) {
		// Ensure that fullsize dimensions are added to metadata array.
		$dimensions = getimagesize( $image_abspath );
		$full_meta  = [
			'width'  => $dimensions[0],
			'height' => $dimensions[1],
			'md5'    => $this->get_image_checksum( $image, 'full' ),
		];

		if ( ! isset( $image->meta_data ) or ( is_string( $image->meta_data ) && strlen( $image->meta_data ) == 0 ) or is_bool( $image->meta_data ) ) {
			$image->meta_data = [];
		}

		$image->meta_data         = array_merge( $image->meta_data, $full_meta );
		$image->meta_data['full'] = $full_meta;

		// Don't forget to append the 'full' entry in meta_data in the db.
		$this->image_mapper->save( $image );
	}

	/**
	 * Most major browsers do not honor the Orientation meta found in EXIF. To prevent display issues we inspect
	 * the EXIF data and rotate the image so that the EXIF field is not necessary to display the image correctly.
	 * Note: generate_image_clone() will handle the removal of the Orientation tag inside the image EXIF.
	 * Note: This only handles single-dimension rotation; at the time this method was written there are no known
	 * camera manufacturers that both rotate and flip images.
	 *
	 * @param $image
	 * @param bool  $save
	 */
	public function correct_exif_rotation( $image, $save = true ) {
		$image_abspath = $this->get_image_abspath( $image, 'full' );

		if ( ! EXIFWriter::is_jpeg_file( $image_abspath ) ) {
			return;
		}

		// This method is necessary.
		if ( ! function_exists( 'exif_read_data' ) ) {
			return;
		}

		// We only need to continue if the Orientation tag is set.
		$exif = @exif_read_data( $image_abspath, 'exif' );
		if ( empty( $exif['Orientation'] ) || $exif['Orientation'] == 1 ) {
			return;
		}

		$degree = 0;
		if ( $exif['Orientation'] == 3 ) {
			$degree = 180;
		}
		if ( $exif['Orientation'] == 6 ) {
			$degree = 90;
		}
		if ( $exif['Orientation'] == 8 ) {
			$degree = 270;
		}

		$parameters = [ 'rotation' => $degree ];

		$generated = $this->generate_image_clone(
			$image_abspath,
			$image_abspath,
			$this->get_image_size_params( $image, 'full', $parameters ),
			$parameters
		);

		if ( $generated && $save ) {
			$this->update_image_dimension_metadata( $image, $image_abspath );
		}

		if ( $generated ) {
			$generated->destruct();
		}
	}

	/**
	 * Flushes the cache we use for path/url calculation for galleries
	 */
	public function flush_gallery_path_cache( $gallery ) {
		$gallery = is_numeric( $gallery ) ? $gallery : $gallery->gid;
		unset( self::$gallery_abspath_cache[ $gallery ] );
	}

	/**
	 * Returns the absolute path to the cache directory of a gallery.
	 *
	 * Without the gallery parameter the legacy (pre 2.0) shared directory is returned.
	 *
	 * @param int|object|false|Gallery $gallery (optional)
	 * @return string Absolute path to cache directory
	 */
	public function get_cache_abspath( $gallery = false ) {
		return path_join( $this->get_gallery_abspath( $gallery ), 'cache' );
	}

	/**
	 * Gets the absolute path where the full-sized image is stored
	 *
	 * @param int|object $image
	 * @return null|string
	 */
	public function get_full_abspath( $image ) {
		return $this->get_image_abspath( $image, 'full' );
	}

	/**
	 * Alias to get_image_dimensions()
	 *
	 * @param int|object $image
	 * @return array
	 */
	public function get_full_dimensions( $image ) {
		return $this->get_image_dimensions( $image, 'full' );
	}

	/**
	 * Alias for get_original_url()
	 *
	 * @param Image $image
	 * @return string
	 */
	public function get_full_url( $image ) {
		return $this->get_image_url( $image, 'full' );
	}

	public function get_gallery_root() {
		return wp_normalize_path( Filesystem::get_instance()->get_document_root( 'galleries' ) );
	}

	public function get_computed_gallery_abspath( $gallery ) {
		$retval       = null;
		$gallery_root = $this->get_gallery_root();

		// Get the gallery entity from the database.
		if ( $gallery ) {
			if ( is_numeric( $gallery ) ) {
				$gallery = $this->gallery_mapper->find( $gallery );
			}
		}

		// It just doesn't exist.
		if ( ! $gallery ) {
			return $retval;
		}

		// We we have a gallery, determine it's path.
		if ( $gallery ) {
			if ( isset( $gallery->path ) ) {
				$retval = $gallery->path;
			} elseif ( isset( $gallery->slug ) ) {
				$basepath = wp_normalize_path( Settings::get_instance()->gallerypath );
				$retval   = path_join( $basepath, $this->sanitize_directory_name( sanitize_title( $gallery->slug ) ) );
			}

			// Normalize the gallery path. If the gallery path starts with /wp-content, and
			// NGG_GALLERY_ROOT_TYPE is set to 'content', then we need to strip out the /wp-content
			// from the start of the gallery path.
			if ( NGG_GALLERY_ROOT_TYPE === 'content' ) {
				$retval = preg_replace( '#^/?wp-content#', '', $retval );
			}

			// Ensure that the path is absolute.
			if ( strpos( $retval, $gallery_root ) !== 0 ) {

				// path_join() behaves funny - if the second argument starts with a slash,
				// it won't join the two paths together.
				$retval = preg_replace( '#^/#', '', $retval );
				$retval = path_join( $gallery_root, $retval );
			}

			$retval = wp_normalize_path( $retval );
		}

		return $retval;
	}

	/**
	 * Get the abspath to the gallery folder for the given gallery
	 * The gallery may or may not already be persisted
	 *
	 * @param int|object|Gallery $gallery
	 *
	 * @return string
	 */
	public function get_gallery_abspath( $gallery ) {
		$gallery_id = is_numeric( $gallery ) ? $gallery : ( is_object( $gallery ) && isset( $gallery->gid ) ? $gallery->gid : null );

		if ( ! $gallery_id || ! isset( self::$gallery_abspath_cache[ $gallery_id ] ) ) {
			self::$gallery_abspath_cache[ $gallery_id ] = $this->get_computed_gallery_abspath( $gallery );
		}

		return self::$gallery_abspath_cache[ $gallery_id ];
	}

	public function get_gallery_relpath( $gallery ) {
		// Special hack for home.pl: their document root is just '/'.
		$root = $this->get_gallery_root();
		if ( $root === '/' ) {
			return $this->get_gallery_abspath( $gallery );
		}

		return str_replace( $this->get_gallery_root(), '', $this->get_gallery_abspath( $gallery ) );
	}

	/**
	 * Gets the absolute path where the image is stored. Can optionally return the path for a particular sized image.
	 *
	 * @param int|object $image
	 * @param string     $size (optional) Default = full
	 * @return string
	 */
	public function get_computed_image_abspath( $image, $size = 'full', $check_existance = false ) {
		$retval = null;

		// If we have the id, get the actual image entity.
		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		// Ensure we have the image entity - user could have passed in an incorrect id.
		if ( is_object( $image ) ) {
			if ( ( $gallery_path = $this->get_gallery_abspath( $image->galleryid ) ) ) {
				$folder = $prefix = $size;
				switch ( $size ) {

					// Images are stored in the associated gallery folder.
					case 'full':
						$retval = \path_join( $gallery_path, $image->filename );
						break;

					case 'backup':
						$retval = \path_join( $gallery_path, $image->filename . '_backup' );
						if ( ! @file_exists( $retval ) ) {
							$retval = \path_join( $gallery_path, $image->filename );
						}
						break;

					case 'thumbnail':
						$size   = 'thumbnail';
						$folder = 'thumbs';
						$prefix = 'thumbs';
						// deliberately no break here.
					default:
						// NGG 2.0 stores relative filenames in the meta data of
						// an image. It does this because it uses filenames
						// that follow conventional WordPress naming scheme.
						$image_path = null;
						$dynthumbs  = \Imagely\NGG\DynamicThumbnails\Manager::get_instance();
						if ( isset( $image->meta_data ) && isset( $image->meta_data[ $size ] ) && isset( $image->meta_data[ $size ]['filename'] ) ) {
							if ( $dynthumbs && $dynthumbs->is_size_dynamic( $size ) ) {
								$image_path = \path_join( $this->get_cache_abspath( $image->galleryid ), $image->meta_data[ $size ]['filename'] );
							} else {
								$image_path = \path_join( $gallery_path, $folder );
								$image_path = \path_join( $image_path, $image->meta_data[ $size ]['filename'] );
							}
						}

						// Filename not found in meta, but is dynamic.
						elseif ( $dynthumbs && $dynthumbs->is_size_dynamic( $size ) ) {
							$params     = $dynthumbs->get_params_from_name( $size, true );
							$image_path = \path_join( $this->get_cache_abspath( $image->galleryid ), $dynthumbs->get_image_name( $image, $params ) );

							// Filename is not found in meta, nor dynamic.
						} else {
							$settings = Settings::get_instance();

							// This next bit is annoying but necessary for legacy reasons. NextGEN until 3.19 stored thumbnails
							// with a filename of "thumbs_(whatever.jpg)" which Google indexes as "thumbswhatever.jpg" which is
							// not good for SEO. From 3.19 on the default setting is "thumbs-" but we must account for legacy
							// sites.
							$image_path     = \path_join( $gallery_path, $folder );
							$new_thumb_path = \path_join( $image_path, "{$prefix}-{$image->filename}" );
							$old_thumb_path = \path_join( $image_path, "{$prefix}_{$image->filename}" );

							if ( $settings->get( 'dynamic_image_filename_separator_use_dash', false ) ) {
								// Check for thumbs- first.
								if ( file_exists( $new_thumb_path ) ) {
									$image_path = $new_thumb_path;
								} elseif ( file_exists( $old_thumb_path ) ) {
									// Check for thumbs_ as a fallback.
									$image_path = $old_thumb_path;
								} else { // The thumbnail file does not exist, default to thumbs-.
									$image_path = $new_thumb_path;
								}
							} else {
								// Reversed: the option is disabled so check for thumbs_.
								if ( file_exists( $old_thumb_path ) ) {
									$image_path = $old_thumb_path;
								} elseif ( file_exists( $new_thumb_path ) ) {
									// In case the user has switched back and forth, check for thumbs-.
									$image_path = $new_thumb_path;
								} else { // Default to thumbs_ per the site setting.
									$image_path = $old_thumb_path;
								}
							}
						}

						$retval = $image_path;
						break;
				}
			}
		}
		if ( $retval && $check_existance && ! @file_exists( $retval ) ) {
			$retval = null;
		}
		return $retval;
	}

	public function get_image_checksum( $image, $size = 'full' ) {
		$retval = null;
		if ( ( $image_abspath = $this->get_image_abspath( $image, $size, true ) ) ) {
			$retval = md5_file( $image_abspath );
		}
		return $retval;
	}

	/**
	 * Gets the dimensions for a particular-sized image
	 *
	 * @param int|object $image
	 * @param string     $size
	 * @return null|array
	 */
	public function get_image_dimensions( $image, $size = 'full' ) {
		$retval = null;

		// If an image id was provided, get the entity.
		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		// Ensure we have a valid image.
		if ( $image ) {

			$size = $this->normalize_image_size_name( $size );
			if ( ! $size ) {
				$size = 'full';
			}

			// Image dimensions are stored in the $image->meta_data
			// property for all implementations.
			if ( isset( $image->meta_data ) && isset( $image->meta_data[ $size ] ) ) {
				$retval = $image->meta_data[ $size ];
			}

			// Didn't exist for meta data. We'll have to compute
			// dimensions in the meta_data after computing? This is most likely
			// due to a dynamic image size being calculated for the first time.
			else {
				$dynthumbs = \Imagely\NGG\DynamicThumbnails\Manager::get_instance();
				$abspath   = $this->get_image_abspath( $image, $size, true );
				if ( $abspath ) {
					$dims = @getimagesize( $abspath );
					if ( $dims ) {
						$retval['width']  = $dims[0];
						$retval['height'] = $dims[1];
					}
				} elseif ( $size == 'backup' ) {
					$retval = $this->get_image_dimensions( $image, 'full' );
				}

				if ( ! $retval && $dynthumbs && $dynthumbs->is_size_dynamic( $size ) ) {
					$new_dims = $this->calculate_image_size_dimensions( $image, $size );
					// Prevent a possible PHP warning if the sizes weren't calculated.
					if ( isset( $new_dims['real_width'] ) && isset( $new_dims['real_height'] ) ) {
						$retval = [
							'width'  => $new_dims['real_width'],
							'height' => $new_dims['real_height'],
						];
					}
				}
			}
		}

		return $retval;
	}

	public function get_image_format_list() {
		$format_list = [
			IMAGETYPE_GIF  => 'gif',
			IMAGETYPE_JPEG => 'jpg',
			IMAGETYPE_PNG  => 'png',
			IMAGETYPE_WEBP => 'webp',
		];

		return $format_list;
	}

	/**
	 * Gets the HTML for an image
	 *
	 * @param int|object $image
	 * @param string     $size
	 * @param array      $attributes (optional)
	 * @return string
	 */
	public function get_image_html( $image, $size = 'full', $attributes = [] ) {
		$retval = '';

		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		if ( $image ) {

			// Set alt text if not already specified.
			if ( ! isset( $attributes['alttext'] ) ) {
				$attributes['alt'] = esc_attr( $image->alttext );
			}

			// Set the title if not already set.
			if ( ! isset( $attributes['title'] ) ) {
				$attributes['title'] = esc_attr( $image->alttext );
			}

			// Set the dimensions if not set already.
			if ( ! isset( $attributes['width'] ) or ! isset( $attributes['height'] ) ) {
				$dimensions = $this->get_image_dimensions( $image, $size );
				if ( ! isset( $attributes['width'] ) ) {
					$attributes['width'] = $dimensions['width'];
				}
				if ( ! isset( $attributes['height'] ) ) {
					$attributes['height'] = $dimensions['height'];
				}
			}

			// Set the url if not already specified.
			if ( ! isset( $attributes['src'] ) ) {
				$attributes['src'] = $this->get_image_url( $image, $size );
			}

			// Format attributes.
			$attribs = [];
			foreach ( $attributes as $attrib => $value ) {
				$attribs[] = "{$attrib}=\"{$value}\"";
			}
			$attribs = implode( ' ', $attribs );

			// Return HTML string.
			$retval = "<img {$attribs} />";
		}

		return $retval;
	}

	public function get_computed_image_url( $image, $size = 'full' ) {
		$retval    = null;
		$dynthumbs = \Imagely\NGG\DynamicThumbnails\Manager::get_instance();

		// Get the image abspath.
		$image_abspath = $this->get_image_abspath( $image, $size );
		if ( $dynthumbs->is_size_dynamic( $size ) && $image_abspath && ! file_exists( $image_abspath ) ) {
			if ( defined( 'NGG_DISABLE_DYNAMIC_IMG_URLS' ) && constant( 'NGG_DISABLE_DYNAMIC_IMG_URLS' ) ) {
				$params = [
					'watermark'  => false,
					'reflection' => false,
					'crop'       => true,
				];
				$result = $this->generate_image_size( $image, $size, $params );
				if ( $result ) {
					$image_abspath = $this->get_image_abspath( $image, $size );
				}
			} else {
				return null;
			}
		}

		// Assuming we have an abspath, we can translate that to a url.
		if ( $image_abspath ) {

			// Replace the gallery root with the proper url segment.
			$gallery_root = preg_quote( $this->get_gallery_root(), '#' );
			$image_uri    = preg_replace(
				"#^{$gallery_root}#",
				'',
				$image_abspath
			);

			// Url encode each uri segment.
			$segments  = explode( '/', $image_uri );
			$segments  = array_map( 'rawurlencode', $segments );
			$image_uri = preg_replace( '#^/#', '', implode( '/', $segments ) );

			// Join gallery root and image uri.
			$gallery_root = trailingslashit( NGG_GALLERY_ROOT_TYPE == 'site' ? site_url() : WP_CONTENT_URL );
			$gallery_root = is_ssl() ? str_replace( 'http:', 'https:', $gallery_root ) : $gallery_root;
			$retval       = $gallery_root . $image_uri;
		}

		return $retval;
	}

	public function normalize_image_size_name( $size = 'full' ) {
		switch ( $size ) {
			case 'full':
			case 'image':
			case 'orig':
			case 'original':
			case 'resized':
				$size = 'full';
				break;
			case 'thumb':
			case 'thumbnail':
			case 'thumbnails':
			case 'thumbs':
				$size = 'thumbnail';
				break;
		}
		return $size;
	}

	/**
	 * Returns the named sizes available for images
	 *
	 * @return array
	 */
	public function get_image_sizes( $image = false ) {
		$retval = [ 'full', 'thumbnail' ];

		if ( is_numeric( $image ) ) {
			$image = ImageMapper::get_instance()->find( $image );
		}

		if ( $image ) {
			if ( $image->meta_data ) {
				$meta_data = is_object( $image->meta_data ) ? get_object_vars( $image->meta_data ) : $image->meta_data;
				foreach ( $meta_data as $key => $value ) {
					if ( is_array( $value ) && isset( $value['width'] ) && ! in_array( $key, $retval ) ) {
						$retval[] = $key;
					}
				}
			}
		}

		return $retval;
	}

	public function get_image_size_params( $image, $size, $params = [], $skip_defaults = false ) {
		// Get the image entity.
		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		$dynthumbs = \Imagely\NGG\DynamicThumbnails\Manager::get_instance();
		if ( $dynthumbs && $dynthumbs->is_size_dynamic( $size ) ) {
			$named_params = $dynthumbs->get_params_from_name( $size, true );
			if ( ! $params ) {
				$params = [];
			}
			$params = array_merge( $params, $named_params );
		}

		$params = apply_filters( 'ngg_get_image_size_params', $params, $size, $image );

		// Ensure we have a valid image.
		if ( $image ) {
			$settings = Settings::get_instance();

			if ( ! $skip_defaults ) {
				// Get default settings.
				if ( $size == 'full' ) {
					if ( ! isset( $params['quality'] ) ) {
						$params['quality'] = $settings->get( 'imgQuality' );
					}
				} else {
					if ( ! isset( $params['crop'] ) ) {
						$params['crop'] = $settings->get( 'thumbfix' );
					}

					if ( ! isset( $params['quality'] ) ) {
						$params['quality'] = $settings->get( 'thumbquality' );
					}
				}
			}

			// width and height when omitted make generate_image_clone create a clone with original size, so try find defaults regardless of $skip_defaults.
			if ( ! isset( $params['width'] ) || ! isset( $params['height'] ) ) {

				// First test if this is a "known" image size, i.e. if we store these sizes somewhere when users re-generate these sizes from the UI...this is required to be compatible with legacy.
				// try the 2 default built-in sizes, first thumbnail...
				if ( $size == 'thumbnail' ) {
					if ( ! isset( $params['width'] ) ) {
						$params['width'] = $settings->thumbwidth;
					}

					if ( ! isset( $params['height'] ) ) {
						$params['height'] = $settings->thumbheight;
					}
				}
				// ...and then full, which is the size specified in the global resize options.
				elseif ( $size == 'full' ) {
					if ( ! isset( $params['width'] ) ) {
						if ( $settings->imgAutoResize ) {
							$params['width'] = $settings->imgWidth;
						}
					}

					if ( ! isset( $params['height'] ) ) {
						if ( $settings->imgAutoResize ) {
							$params['height'] = $settings->imgHeight;
						}
					}
				}
				// Only re-use old sizes as last resort.
				elseif ( isset( $image->meta_data ) && isset( $image->meta_data[ $size ] ) ) {
					$dimensions = $image->meta_data[ $size ];

					if ( ! isset( $params['width'] ) ) {
						$params['width'] = $dimensions['width'];
					}

					if ( ! isset( $params['height'] ) ) {
						$params['height'] = $dimensions['height'];
					}
				}
			}

			if ( ! isset( $params['crop_frame'] ) ) {
				$crop_frame_size_name = 'thumbnail';

				if ( isset( $image->meta_data[ $size ]['crop_frame'] ) ) {
					$crop_frame_size_name = $size;
				}

				if ( isset( $image->meta_data[ $crop_frame_size_name ]['crop_frame'] ) ) {
					$params['crop_frame'] = $image->meta_data[ $crop_frame_size_name ]['crop_frame'];

					if ( ! isset( $params['crop_frame']['final_width'] ) ) {
						$params['crop_frame']['final_width'] = $image->meta_data[ $crop_frame_size_name ]['width'];
					}

					if ( ! isset( $params['crop_frame']['final_height'] ) ) {
						$params['crop_frame']['final_height'] = $image->meta_data[ $crop_frame_size_name ]['height'];
					}
				}
			} else {
				if ( ! isset( $params['crop_frame']['final_width'] ) ) {
					$params['crop_frame']['final_width'] = $params['width'];
				}

				if ( ! isset( $params['crop_frame']['final_height'] ) ) {
					$params['crop_frame']['final_height'] = $params['height'];
				}
			}
		}

		return $params;
	}

	/**
	 * Alias to get_image_dimensions()
	 *
	 * @param int|object $image
	 * @return array
	 */
	public function get_original_dimensions( $image ) {
		return $this->get_image_dimensions( $image, 'full' );
	}

	/**
	 * @param object|bool $gallery (optional)
	 * @return string
	 */
	public function get_upload_abspath( $gallery = false ) {
		// Base upload path.
		$retval = Settings::get_instance()->get( 'gallerypath' );
		$fs     = Filesystem::get_instance();

		// Append the slug if a gallery has been specified.
		if ( $gallery ) {
			$retval = $this->get_gallery_abspath( $gallery );
		}

		// We need to make this an absolute path.
		if ( ! empty( $retval ) && strpos( $retval, $fs->get_document_root( 'gallery' ) ) !== 0 ) {
			$retval = rtrim( $fs->join_paths( $fs->get_document_root( 'gallery' ), $retval ), '/\\' );
		}

		// Convert slashes.
		return wp_normalize_path( $retval );
	}

	/**
	 * Gets the upload path, optionally for a particular gallery
	 *
	 * @param int|Gallery|object|false $gallery (optional)
	 *
	 * @return string
	 */
	public function get_upload_relpath( $gallery = false ) {
		$fs = Filesystem::get_instance();

		$retval = str_replace(
			$fs->get_document_root( 'gallery' ),
			'',
			$this->get_upload_abspath( $gallery )
		);

		return '/' . wp_normalize_path( ltrim( $retval, '/' ) );
	}

	public function delete_gallery_directory( $abspath ) {
		// Remove all image files and purge all empty directories left over.
		$iterator = new \DirectoryIterator( $abspath );

		// Only delete image files! Other files may be stored incorrectly but it's not our place to delete them.
		$removable_extensions = apply_filters( 'ngg_allowed_file_types', NGG_DEFAULT_ALLOWED_FILE_TYPES );
		foreach ( $removable_extensions as $extension ) {
			$removable_extensions[] = $extension . '_backup';
		}

		foreach ( $iterator as $file ) {
			if ( in_array( $file->getBasename(), [ '.', '..' ] ) ) {
				continue;

			} elseif ( $file->isFile() || $file->isLink() ) {
				$extension = strtolower( pathinfo( $file->getPathname(), PATHINFO_EXTENSION ) );
				if ( in_array( $extension, $removable_extensions, true ) ) {
					@unlink( $file->getPathname() );
				}
			} elseif ( $file->isDir() ) {
				$this->delete_gallery_directory( $file->getPathname() );
			}
		}

		// DO NOT remove directories that still have files in them. Note: '.' and '..' are included with getSize().
		$empty = true;
		foreach ( $iterator as $file ) {
			if ( in_array( $file->getBasename(), [ '.', '..' ] ) ) {
				continue;
			}
			$empty = false;
		}
		if ( $empty ) {
			@rmdir( $iterator->getPath() );
		}
	}

	/**
	 * @param Image[]     $images
	 * @param Gallery|int $dst_gallery
	 *
	 * @return int[]
	 */
	public function copy_images( $images, $dst_gallery ) {
		$retval = [];

		// Ensure that the image ids we have are valid.
		$image_mapper = ImageMapper::get_instance();
		foreach ( $images as $image ) {
			if ( is_numeric( $image ) ) {
				$image = $image_mapper->find( $image );
			}

			$image_abspath = $this->get_image_abspath( $image, 'backup' ) ?: $this->get_image_abspath( $image );

			if ( $image_abspath ) {
				// Import the image; this will copy the main file.
				$new_image_id = $this->import_image_file( $dst_gallery, $image_abspath, $image->filename );

				if ( $new_image_id ) {
					// Copy the properties of the old image.
					$new_image = $image_mapper->find( $new_image_id );
					foreach ( get_object_vars( $image ) as $key => $value ) {
						if ( in_array( $key, [ 'pid', 'galleryid', 'meta_data', 'filename', 'sortorder', 'extras_post_id' ] ) ) {
							continue;
						}
						$new_image->$key = $value;
					}
					$image_mapper->save( $new_image );

					// Copy tags.
					$tags = wp_get_object_terms( $image->pid, 'ngg_tag', 'fields=ids' );
					$tags = array_map( 'intval', $tags );
					wp_set_object_terms( $new_image_id, $tags, 'ngg_tag', true );

					// Copy all of the generated versions (resized versions, watermarks, etc).
					foreach ( $this->get_image_sizes( $image ) as $named_size ) {
						if ( in_array( $named_size, [ 'full', 'thumbnail' ] ) ) {
							continue;
						}
						$old_abspath = $this->get_image_abspath( $image, $named_size );
						$new_abspath = $this->get_image_abspath( $new_image, $named_size );
						if ( is_array( @stat( $old_abspath ) ) ) {
							$new_dir = dirname( $new_abspath );
							// Ensure the target directory exists.
							if ( @stat( $new_dir ) === false ) {
								wp_mkdir_p( $new_dir );
							}
							@copy( $old_abspath, $new_abspath );
						}
					}

					// Mark as done.
					$retval[] = $new_image_id;
				}
			}
		}

		return $retval;
	}

	/**
	 * Moves images from to another gallery
	 *
	 * @param array      $images
	 * @param int|object $gallery
	 * @return int[]
	 */
	public function move_images( $images, $gallery ) {
		$retval = $this->copy_images( $images, $gallery );

		if ( $images ) {
			foreach ( $images as $image_id ) {
				$this->delete_image( $image_id );
			}
		}

		return $retval;
	}

	/**
	 * @param string $abspath
	 * @return bool
	 */
	public function delete_directory( $abspath ) {
		$retval = false;

		if ( @file_exists( $abspath ) ) {
			$files = scandir( $abspath );
			array_shift( $files );
			array_shift( $files );
			foreach ( $files as $file ) {
				$file_abspath = implode( DIRECTORY_SEPARATOR, [ rtrim( $abspath, '/\\' ), $file ] );
				if ( is_dir( $file_abspath ) ) {
					$this->delete_directory( $file_abspath );
				} else {
					unlink( $file_abspath );
				}
			}
			rmdir( $abspath );
			$retval = @file_exists( $abspath );
		}

		return $retval;
	}

	public function delete_gallery( $gallery ) {
		$fs        = Filesystem::get_instance();
		$safe_dirs = [
			DIRECTORY_SEPARATOR,
			$fs->get_document_root( 'plugins' ),
			$fs->get_document_root( 'plugins_mu' ),
			$fs->get_document_root( 'templates' ),
			$fs->get_document_root( 'stylesheets' ),
			$fs->get_document_root( 'content' ),
			$fs->get_document_root( 'galleries' ),
			$fs->get_document_root(),
		];

		$abspath = $this->get_gallery_abspath( $gallery );

		if ( $abspath && file_exists( $abspath ) && ! in_array( stripslashes( $abspath ), $safe_dirs ) ) {
			$this->delete_gallery_directory( $abspath );
		}
	}

	/**
	 * @param Image        $image
	 * @param string|false $size
	 * @return bool
	 */
	public function delete_image( $image, $size = false ) {
		$retval = false;

		// Ensure that we have the image entity.
		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		if ( $image ) {
			$image_id = $image->{$image->id_field};
			do_action( 'ngg_delete_image', $image_id, $size );

			// Delete only a particular image size.
			if ( $size ) {
				$abspath = $this->get_image_abspath( $image, $size );
				if ( $abspath && @file_exists( $abspath ) ) {
					@unlink( $abspath );
				}
				if ( isset( $image->meta_data ) && isset( $image->meta_data[ $size ] ) ) {
					unset( $image->meta_data[ $size ] );
					$this->image_mapper->save( $image );
				}
			}
			// Delete all sizes of the image.
			else {
				foreach ( $this->get_image_sizes( $image ) as $named_size ) {

					$image_abspath = $this->get_image_abspath( $image, $named_size );
					@unlink( $image_abspath );
				}

				// Delete the entity.
				$this->image_mapper->destroy( $image );
			}
			$retval = true;
		}

		return $retval;
	}

	/**
	 * Outputs/renders an image
	 *
	 * @param Image $image
	 * @return bool
	 */
	public function render_image( $image, $size = false ) {
		$format_list = $this->get_image_format_list();
		$abspath     = $this->get_image_abspath( $image, $size, true );

		if ( $abspath == null ) {
			$thumbnail = $this->generate_image_size( $image, $size );

			if ( $thumbnail != null ) {
				$abspath = $thumbnail->fileName;

				$thumbnail->destruct();
			}
		}

		if ( $abspath != null ) {
			$data   = @getimagesize( $abspath );
			$format = 'jpg';

			if ( $data != null && is_array( $data ) && isset( $format_list[ $data[2] ] ) ) {
				$format = $format_list[ $data[2] ];
			}

			// Clear output.
			while ( ob_get_level() > 0 ) {
				ob_end_clean();
			}

			$format = strtolower( $format );

			// output image and headers.
			header( 'Content-type: image/' . $format );
			readfile( $abspath );

			return true;
		}

		return false;
	}

	/**
	 * Recover image from backup copy and reprocess it
	 *
	 * @param Image $image
	 * @return bool|string result code
	 */
	public function recover_image( $image ) {
		$retval = false;

		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		if ( $image ) {
			$full_abspath   = $this->get_image_abspath( $image );
			$backup_abspath = $this->get_image_abspath( $image, 'backup' );

			if ( $backup_abspath != $full_abspath && @file_exists( $backup_abspath ) ) {
				if ( is_writable( $full_abspath ) && is_writable( dirname( $full_abspath ) ) ) {
					// Copy the backup.
					if ( @copy( $backup_abspath, $full_abspath ) ) {
						// Backup images are not altered at all; we must re-correct the EXIF/Orientation tag.
						$this->correct_exif_rotation( $image, true );

						// Re-create non-fullsize image sizes.
						foreach ( $this->get_image_sizes( $image ) as $named_size ) {
							if ( in_array( $named_size, [ 'full', 'backup' ] ) ) {
								continue;
							}

							// Reset thumbnail cropping set by 'Edit thumb' dialog.
							if ( $named_size === 'thumbnail' ) {
								unset( $image->meta_data[ $named_size ]['crop_frame'] );
							}

							$thumbnail = $this->generate_image_clone(
								$full_abspath,
								$this->get_image_abspath( $image, $named_size ),
								$this->get_image_size_params( $image, $named_size )
							);
							if ( $thumbnail ) {
								$thumbnail->destruct();
							}
						}

						do_action( 'ngg_recovered_image', $image );

						// Reimport all metadata.
						$retval = $this->image_mapper->reimport_metadata( $image );
					}
				}
			}
		}

		return $retval;
	}

	/**
	 * Copies a NGG image to the media library and returns the attachment_id
	 *
	 * @param Image $image
	 * @return false|int attachment_id
	 */
	public function copy_to_media_library( $image ) {
		$retval = false;

		// Get the image.
		if ( is_int( $image ) ) {
			$imageId = $image;
			$mapper  = ImageMapper::get_instance();
			$image   = $mapper->find( $imageId );
		}

		if ( $image ) {
			$subdir = apply_filters( 'ngg_import_to_media_library_subdir', 'nggallery_import' );

			$wordpress_upload_dir = wp_upload_dir();
			$path                 = $wordpress_upload_dir['path'] . DIRECTORY_SEPARATOR . $subdir;

			if ( ! file_exists( $path ) ) {
				wp_mkdir_p( $path );
			}

			$image_abspath = $this->get_image_abspath( $image, 'full' );
			$new_file_path = $path . DIRECTORY_SEPARATOR . $image->filename;

			$image_data    = getimagesize( $image_abspath );
			$new_file_mime = $image_data['mime'];

			$i = 1;
			while ( file_exists( $new_file_path ) ) {
				++$i;
				$new_file_path = $path . DIRECTORY_SEPARATOR . $i . '-' . $image->filename;
			}

			if ( @copy( $image_abspath, $new_file_path ) ) {
				$upload_id = wp_insert_attachment(
					[
						'guid'           => $new_file_path,
						'post_mime_type' => $new_file_mime,
						'post_title'     => preg_replace( '/\.[^.]+$/', '', $image->alttext ),
						'post_content'   => '',
						'post_status'    => 'inherit',
					],
					$new_file_path
				);

				update_post_meta( $upload_id, '_ngg_image_id', intval( $image->pid ) );

				// wp_generate_attachment_metadata() comes from this file.
				require_once ABSPATH . 'wp-admin/includes/image.php';

				$image_meta = wp_generate_attachment_metadata( $upload_id, $new_file_path );

				// Generate and save the attachment metas into the database.
				wp_update_attachment_metadata( $upload_id, $image_meta );

				$retval = $upload_id;
			}
		}

		return $retval;
	}

	/**
	 * Delete the given NGG image from the media library
	 *
	 * @var int|stdClass $imageId
	 */
	public function delete_from_media_library( $imageId ) {
		// Get the image.
		if ( ! is_int( $imageId ) ) {
			$image   = $imageId;
			$imageId = $image->pid;
		}

		if ( ( $postId = $this->is_in_media_library( $imageId ) ) ) {
			wp_delete_post( $postId );
		}
	}

	/**
	 * Determines if the given NGG image id has been uploaded to the media library
	 *
	 * @param integer $imageId
	 * @return false|int attachment_id
	 */
	public function is_in_media_library( $imageId ) {
		$retval = false;

		// Get the image.
		if ( is_object( $imageId ) ) {
			$image   = $imageId;
			$imageId = $image->pid;
		}

		// Try to find an attachment for the given image_id.
		if ( $imageId ) {
			$query = new \WP_Query(
				[
					'post_type'      => 'attachment',
					'meta_key'       => '_ngg_image_id',
					'meta_value_num' => $imageId,
				]
			);

			foreach ( $query->get_posts() as $post ) {
				$retval = $post->ID;
			}
		}

		return $retval;
	}

	/**
	 * @param string $filename
	 * @return bool
	 */
	public function is_allowed_image_extension( $filename ) {
		$extension = pathinfo( $filename, PATHINFO_EXTENSION );
		$extension = strtolower( $extension );

		$allowed_extensions = apply_filters( 'ngg_allowed_file_types', NGG_DEFAULT_ALLOWED_FILE_TYPES );

		foreach ( $allowed_extensions as $extension ) {
			$allowed_extensions[] = $extension . '_backup';
		}

		return in_array( $extension, $allowed_extensions );
	}

	public function is_current_user_over_quota() {
		$retval   = false;
		$settings = Settings::get_instance();

		if ( ( is_multisite() ) && $settings->get( 'wpmuQuotaCheck' ) ) {
			require_once ABSPATH . 'wp-admin/includes/ms.php';
			$retval = upload_is_user_over_quota( false );
		}

		return $retval;
	}

	/**
	 * @param string? $filename
	 * @return bool
	 */
	public function is_image_file( $filename = null ): bool {
		$retval = false;

		// Security::verify_nonce() is a wrapper to wp_verify_nonce().
		//
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( ! $filename
			&& isset( $_FILES['file']['error'] )
			&& isset( $_FILES['file']['tmp_name'] )
			&& 0 === $_FILES['file']['error'] ) {

			// Windows' use of backslash characters for file paths means wp_unslash() here is destructive.
			if ( 0 === strncasecmp( PHP_OS, 'WIN', 3 ) ) {
				// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
				$filename = sanitize_text_field( $_FILES['file']['tmp_name'] );
			} else {
				$filename = sanitize_text_field( wp_unslash( $_FILES['file']['tmp_name'] ) );
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$allowed_mime = apply_filters( 'ngg_allowed_mime_types', NGG_DEFAULT_ALLOWED_MIME_TYPES );

		// If we can, we'll verify the mime type.
		if ( function_exists( 'exif_imagetype' ) ) {
			if ( ( $image_type = @exif_imagetype( $filename ) ) !== false ) {
				$retval = in_array( image_type_to_mime_type( $image_type ), $allowed_mime );
			}
		} else {
			$file_info = @getimagesize( $filename );
			if ( isset( $file_info[2] ) ) {
				$retval = in_array( image_type_to_mime_type( $file_info[2] ), $allowed_mime );
			}
		}

		return $retval;
	}

	public function is_zip( bool $skip_nonce_check = false ): bool {
		$retval = false;

		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( isset( $_FILES['file']['error'] ) && 0 === $_FILES['file']['error'] ) {
			// Check nonce only if not skipping (for non-REST calls)
			if ( ! $skip_nonce_check ) {
				if ( ! isset( $_REQUEST['nonce'] )
					|| ! Security::verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ), 'nextgen_upload_image' ) ) {
					return false;
				}
			}

			$file_info = $_FILES['file'];

			if ( isset( $file_info['type'] ) ) {
				$type       = $file_info['type'];
				$type_parts = explode( '/', $type );

				if ( strtolower( $type_parts[0] ) == 'application' ) {
					$spec       = $type_parts[1];
					$spec_parts = explode( '-', $spec );
					$spec_parts = array_map( 'strtolower', $spec_parts );

					if ( in_array( $spec, [ 'zip', 'octet-stream' ] ) || in_array( 'zip', $spec_parts ) ) {
						$retval = true;
					}
				}
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		return $retval;
	}

	public function get_unique_abspath( $file_abspath ) {
		$filename    = basename( $file_abspath );
		$dir_abspath = dirname( $file_abspath );
		$num         = 1;

		$pattern = path_join( $dir_abspath, "*_{$filename}" );
		if ( ( $found = glob( $pattern ) ) ) {
			natsort( $found );
			$last = array_pop( $found );
			$last = basename( $last );
			if ( preg_match( '/^(\d+)_/', $last, $match ) ) {
				$num = intval( $match[1] ) + 1;
			}
		}

		return path_join( $dir_abspath, "{$num}_{$filename}" );
	}

	/**
	 * Determines whether a WebP image is animated which GD does not support.
	 *
	 * @see https://developers.google.com/speed/webp/docs/riff_container
	 * @param string $filename
	 * @return bool
	 */
	public function is_animated_webp( $filename ) {
		$retval = false;
		$handle = fopen( $filename, 'rb' );
		fseek( $handle, 12 );
		if ( fread( $handle, 4 ) === 'VP8X' ) {
			fseek( $handle, 20 );
			$flag   = fread( $handle, 1 );
			$retval = (bool) ( ( ( ord( $flag ) >> 1 ) & 1 ) );
		}
		fclose( $handle );

		return $retval;
	}

	public function import_image_file( $dst_gallery, $image_abspath, $filename = null, $image = false, $override = false, $move = false ) {
		$image_abspath = wp_normalize_path( $image_abspath );

		if ( $this->is_current_user_over_quota() ) {
			$message = sprintf( __( 'Sorry, you have used your space allocation. Please delete some files to upload more files.', 'nggallery' ) );
			throw new \E_NoSpaceAvailableException( esc_html( $message ) );
		}

		// Do we have a gallery to import to?
		if ( $dst_gallery ) {
			// Get the gallery abspath. This is where we will put the image files.
			$gallery_abspath = $this->get_gallery_abspath( $dst_gallery );

			// If we can't write to the directory, then there's no point in continuing.
			if ( ! @file_exists( $gallery_abspath ) ) {
				@wp_mkdir_p( $gallery_abspath );
			}
			if ( ! is_writable( $gallery_abspath ) ) {
				throw new \E_InsufficientWriteAccessException( false, $gallery_abspath, false );
			}

			// Sanitize the filename for storing in the DB.
			$filename = $this->sanitize_filename_for_db( $filename );

			// Ensure that the filename is valid.
			$extensions   = apply_filters( 'ngg_allowed_file_types', NGG_DEFAULT_ALLOWED_FILE_TYPES );
			$extensions[] = '_backup';
			$ext_list     = implode( '|', $extensions );

			if ( ! preg_match( "/({$ext_list})\$/i", $filename ) ) {
				throw new \E_UploadException( __( 'Invalid image file. Acceptable formats: JPG, GIF, and PNG.', 'nggallery' ) );
			}
			// GD does not support animated WebP and will generate a fatal error when we try to create thumbnails or resize.
			if ( $this->is_animated_webp( $image_abspath ) ) {
				throw new \E_UploadException( __( 'Animated WebP images are not supported.', 'nggallery' ) );
			}

			// Compute the destination folder.
			$new_image_abspath = path_join( $gallery_abspath, $filename );

			// Are the src and dst the same? If so, we don't have to copy or move files.
			if ( $image_abspath != $new_image_abspath ) {
				// If we're not to override, ensure that the filename is unique.
				if ( ! $override && @file_exists( $new_image_abspath ) ) {
					$new_image_abspath = $this->get_unique_abspath( $new_image_abspath );
					$filename          = $this->sanitize_filename_for_db( basename( $new_image_abspath ) );
				}

				// Try storing the file.
				$copied = copy( $image_abspath, $new_image_abspath );
				if ( $copied && $move ) {
					unlink( $image_abspath );
				}

				// Ensure that we're not vulerable to CVE-2017-2416 exploit.
				if ( ( $dimensions = getimagesize( $new_image_abspath ) ) !== false ) {
					if ( ( isset( $dimensions[0] ) && intval( $dimensions[0] ) > 30000 )
						|| ( isset( $dimensions[1] ) && intval( $dimensions[1] ) > 30000 ) ) {
						unlink( $new_image_abspath );
						throw new \E_UploadException( esc_html( __( 'Image file too large. Maximum image dimensions supported are 30k x 30k.' ) ) );
					}
				}
			}

			// Save the image in the DB.
			$image_mapper            = ImageMapper::get_instance();
			$image_mapper->use_cache = false;
			if ( $image ) {
				if ( is_numeric( $image ) ) {
					$image = $image_mapper->find( $image );
				}
			}
			if ( ! $image ) {
				$image = $image_mapper->create();
			}
			$image->alttext    = preg_replace( '#\.\w{2,4}$#', '', $filename );
			$image->galleryid  = is_numeric( $dst_gallery ) ? $dst_gallery : $dst_gallery->gid;
			$image->filename   = $filename;
			$image->image_slug = \nggdb::get_unique_slug( sanitize_title_with_dashes( $image->alttext ), 'image' );
			$image_id          = $image_mapper->save( $image );

			if ( ! $image_id ) {
				$exception  = '';
				$validation = $image->validation();
				if ( is_array( $validation ) ) {
					foreach ( $validation as $field => $errors ) {
						foreach ( $errors as $error ) {
							if ( ! empty( $exception ) ) {
								$exception .= '<br/>';
							}
							$exception .= __( sprintf( 'Error while uploading %s: %s', $filename, $error ), 'nextgen-gallery' );
						}
					}
					throw new \E_UploadException( $exception );
				}
			}

			// Important: do not remove this line. The image mapper's save() routine imports metadata
			// meaning we must re-acquire a new $image object after saving it above; if we do not our
			// existing $image object will lose any metadata retrieved during said save() method.
			$image = $image_mapper->find( $image_id );

			$image_mapper->use_cache = true;
			$settings                = Settings::get_instance();

			// Backup the image.
			if ( $settings->get( 'imgBackup', false ) ) {
				$this->backup_image( $image, true );
			}

			// Most browsers do not honor EXIF's Orientation header: rotate the image to prevent display issues.
			$this->correct_exif_rotation( $image, true );

			// Create resized version of image.
			if ( $settings->get( 'imgAutoResize', false ) ) {
				$this->generate_resized_image( $image, true );
			}

			// Generate a thumbnail for the image.
			$this->generate_thumbnail( $image );

			// Set gallery preview image if missing.
			GalleryMapper::get_instance()->set_preview_image( $dst_gallery, $image_id, true );

			// Automatically watermark the main image if requested.
			if ( $settings->get( 'watermark_automatically_at_upload', 0 ) ) {
				$image_abspath = $this->get_image_abspath( $image, 'full' );
				$this->generate_image_clone( $image_abspath, $image_abspath, [ 'watermark' => true ] );
			}

			// Notify other plugins that an image has been added.
			do_action( 'ngg_added_new_image', $image );

			// delete dirsize after adding new images.
			delete_transient( 'dirsize_cache' );

			// Seems redundant to above hook. Maintaining for legacy purposes.
			do_action(
				'ngg_after_new_images_added',
				is_numeric( $dst_gallery ) ? $dst_gallery : $dst_gallery->gid,
				[ $image_id ]
			);

			return $image_id;

		} else {
			throw new \E_EntityNotFoundException();
		}

		return null;
	}

	/**
	 * Generates a specific size for an image
	 *
	 * @param Image      $image
	 * @param string     $size
	 * @param array|null $params (optional)
	 * @param bool       $skip_defaults (optional)
	 * @return bool|object
	 */
	public function generate_image_size( $image, $size, $params = null, $skip_defaults = false ) {
		$retval = false;

		// Get the image entity.
		if ( is_numeric( $image ) ) {
			$image = $this->image_mapper->find( $image );
		}

		// Ensure we have a valid image.
		if ( $image ) {
			$params   = $this->get_image_size_params( $image, $size, $params, $skip_defaults );
			$settings = Settings::get_instance();

			// Get the image filename.
			$filename = $this->get_image_abspath( $image, 'full' );

			if ( ! $filename || ! @file_exists( $filename ) ) {
				return false; // bail out if the file doesn't exist.
			}

			$thumbnail = null;

			if ( $size == 'full' && $settings->get( 'imgBackup' ) == 1 ) {
				$backup_path = $this->get_backup_abspath( $image );

				if ( ! @file_exists( $backup_path ) ) {
					@copy( $filename, $backup_path );
				}
			}

			// Generate the thumbnail using WordPress.
			$existing_image_abpath = $this->get_image_abspath( $image, $size );
			$existing_image_dir    = dirname( $existing_image_abpath );

			// Ensure directory exists with proper error handling
			if ( ! \wp_mkdir_p( $existing_image_dir ) ) {
				// Fallback: try to create directory with different permissions
				if ( ! @mkdir( $existing_image_dir, 0755, true ) ) {
					error_log( 'NextGEN Gallery: Failed to create thumbnail directory: ' . $existing_image_dir );
					return false;
				}
			}

			// Verify directory is writable
			if ( ! is_writable( $existing_image_dir ) ) {
				// Try to fix permissions
				@chmod( $existing_image_dir, 0755 );
				if ( ! is_writable( $existing_image_dir ) ) {
					error_log( 'NextGEN Gallery: Thumbnail directory not writable: ' . $existing_image_dir );
					return false;
				}
			}

			$clone_path = $existing_image_abpath;
			$thumbnail  = $this->generate_image_clone( $filename, $clone_path, $params );

			// We successfully generated the thumbnail.
			if ( $thumbnail != null ) {
				$clone_path = $thumbnail->fileName;

				if ( function_exists( 'getimagesize' ) ) {
					$dimensions = getimagesize( $clone_path );
				} else {
					$dimensions = [ $params['width'], $params['height'] ];
				}

				if ( ! isset( $image->meta_data ) ) {
					$image->meta_data = [];
				}

				$size_meta = [
					'width'     => $dimensions[0],
					'height'    => $dimensions[1],
					'filename'  => I18N::mb_basename( $clone_path ),
					'generated' => microtime(),
				];

				if ( isset( $params['crop_frame'] ) ) {
					$size_meta['crop_frame'] = $params['crop_frame'];
				}

				$image->meta_data[ $size ] = $size_meta;

				if ( $size == 'full' ) {
					$image->meta_data['width']  = $size_meta['width'];
					$image->meta_data['height'] = $size_meta['height'];
				}

				$retval = $this->image_mapper->save( $image );

				\do_action( 'ngg_generated_image', $image, $size, $params );

				if ( $retval == 0 ) {
					$retval = false;
				}

				if ( $retval ) {
					$retval = $thumbnail;
				}
			}
		}

		return $retval;
	}

	/**
	 * Generates a thumbnail for an image
	 *
	 * @param Image $image
	 * @return bool
	 */
	public function generate_thumbnail( $image, $params = null, $skip_defaults = false ) {
		static $generating = [];

		// Get image ID for recursion tracking
		$image_id = is_numeric( $image ) ? $image : ( isset( $image->pid ) ? $image->pid : 0 );

		// Prevent infinite recursion
		if ( isset( $generating[ $image_id ] ) ) {
			return false;
		}

		$generating[ $image_id ] = true;

		$sized_image = $this->generate_image_size( $image, 'thumbnail', $params, $skip_defaults );
		$retval      = false;

		if ( $sized_image != null ) {
			$retval = true;
			$sized_image->destruct();
		}

		if ( is_admin() && ( $image = ImageMapper::get_instance()->find( $image ) ) ) {
			$app = Router::get_instance()->get_routed_app();

			$image->thumb_url = $app->set_parameter_value(
				'timestamp',
				time(),
				null,
				$this->get_image_url( $image, 'thumb' ),
				$app->get_routed_url( true )
			);

			$event            = new \stdClass();
			$event->pid       = $image->{$image->id_field};
			$event->id_field  = $image->id_field;
			$event->thumb_url = $image->thumb_url;

			EventPublisher::get_instance()->add_event(
				[
					'event' => 'thumbnail_modified',
					'image' => $event,
				]
			);
		}

		unset( $generating[ $image_id ] );

		return $retval;
	}

	/**
	 * Gets the absolute path of the backup of an original image
	 *
	 * @param object|string $image
	 * @return null|string
	 */
	public function get_backup_abspath( $image ) {
		$retval = null;

		if ( ( $image_path = $this->get_image_abspath( $image ) ) ) {
			$retval = $image_path . '_backup';
		}

		return $retval;
	}

	public function get_backup_dimensions( $image ) {
		return $this->get_image_dimensions( $image, 'backup' );
	}

	/**
	 * Gets the absolute path where the image is stored. Can optionally return the path for a particular sized image
	 *
	 * @param int|object $image
	 * @param string     $size (optional) Default = full
	 * @param bool       $check_existence (optional) Default = false
	 * @return string
	 */
	public function get_image_abspath( $image, $size = 'full', $check_existence = false ) {
		$image_id = is_numeric( $image ) ? $image : $image->pid;
		$size     = $this->normalize_image_size_name( $size );
		$key      = strval( $image_id ) . $size;

		if ( $check_existence || ! isset( self::$image_abspath_cache[ $key ] ) ) {
			self::$image_abspath_cache[ $key ] = $this->get_computed_image_abspath( $image, $size, $check_existence );
		}

		return self::$image_abspath_cache[ $key ];
	}

	/**
	 * Gets the url of a particular-sized image
	 *
	 * @param int|object $image
	 * @param string     $size
	 * @return string
	 */
	public function get_image_url( $image, $size = 'full' ) {
		$retval   = null;
		$image_id = is_numeric( $image ) ? $image : $image->pid;
		$key      = strval( $image_id ) . $size;
		$success  = true;

		if ( ! isset( self::$image_url_cache[ $key ] ) ) {
			$url = $this->get_computed_image_url( $image, $size );
			if ( $url ) {
				self::$image_url_cache[ $key ] = $url;
				$success                       = true;
			} else {
				$success = false;
			}
		}
		if ( $success ) {
			$retval = self::$image_url_cache[ $key ];
		} else {
			$dynthumbs = \Imagely\NGG\DynamicThumbnails\Manager::get_instance();
			if ( $dynthumbs->is_size_dynamic( $size ) ) {
				$params = $dynthumbs->get_params_from_name( $size );
				$retval = Router::get_instance()->get_url(
					$dynthumbs->get_image_uri( $image, $params ),
					false,
					'root'
				);
			}
		}

		// Fallback: If thumbnail URL is still null and we're requesting a thumbnail,
		// try to generate it on-the-fly (useful for fresh installations)
		if ( ! $retval && ( $size === 'thumbnail' || $size === 'thumb' ) ) {
			// Attempt to generate the thumbnail
			if ( $this->generate_thumbnail( $image ) ) {
				// Clear the cache and try again
				unset( self::$image_url_cache[ $key ] );
				$url = $this->get_computed_image_url( $image, $size );
				if ( $url ) {
					self::$image_url_cache[ $key ] = $url;
					$retval = $url;
				}
			}
		}

		return apply_filters( 'ngg_get_image_url', $retval, $image, $size );
	}

	/**
	 * Flushes the cache we use for path/url calculation for images
	 */
	public function flush_image_path_cache( $image, $size ) {
		$image = is_numeric( $image ) ? $image : $image->pid;
		$key   = strval( $image ) . $size;

		unset( self::$image_abspath_cache[ $key ] );
		unset( self::$image_url_cache[ $key ] );
	}

	/**
	 * Check if ImageMagick supports JPEG format
	 *
	 * @return bool
	 */
	private function imagick_supports_jpeg() {
		static $supports_jpeg = null;

		if ( $supports_jpeg !== null ) {
			return $supports_jpeg;
		}

		$supports_jpeg = false;

		if ( extension_loaded( 'imagick' ) && class_exists( 'Imagick' ) ) {
			try {
				$imagick = new \Imagick();
				$formats = $imagick->queryFormats( 'JPEG' );
				$supports_jpeg = ! empty( $formats );
				$imagick->clear();
				$imagick->destroy();
			} catch ( \Exception $e ) {
				// ImageMagick error, assume no JPEG support
				error_log( 'NextGEN Gallery: ImageMagick JPEG support check failed: ' . $e->getMessage() );
				$supports_jpeg = false;
			}
		}

		return $supports_jpeg;
	}

	/**
	 * @param string        $abspath
	 * @param int           $gallery_id
	 * @param bool          $create_new_gallerypath
	 * @param null|string   $gallery_title
	 * @param array[string] $filenames
	 * @return array|bool false on failure
	 */
	public function import_gallery_from_fs( $abspath, $gallery_id = null, $create_new_gallerypath = true, $gallery_title = null, $filenames = [] ) {
		if ( @ ! file_exists( $abspath ) ) {
			return false;
		}

		$fs = Filesystem::get_instance();

		$retval = [ 'image_ids' => [] ];

		// Ensure that this folder has images.
		$files       = [];
		$directories = [];
		foreach ( scandir( $abspath ) as $file ) {
			if ( $file == '.' || $file == '..' || strtoupper( $file ) == '__MACOSX' ) {
				continue;
			}

			$file_abspath = $fs->join_paths( $abspath, $file );

			// Omit 'hidden' directories prefixed with a period.
			if ( is_dir( $file_abspath ) && strpos( $file, '.' ) !== 0 ) {
				$directories[] = $file_abspath;
			} elseif ( $this->is_image_file( $file_abspath ) ) {
				if ( $filenames && array_search( $file_abspath, $filenames ) !== false ) {
					$files[] = $file_abspath;
				} elseif ( ! $filenames ) {
					$files[] = $file_abspath;
				}
			}
		}

		if ( empty( $files ) && empty( $directories ) ) {
			return false;
		}

		// Get needed utilities.
		$gallery_mapper = GalleryMapper::get_instance();

		// Recurse through the directory and pull in all of the valid images we find.
		if ( ! empty( $directories ) ) {
			foreach ( $directories as $dir ) {
				$subImport = $this->import_gallery_from_fs( $dir, $gallery_id, $create_new_gallerypath, $gallery_title, $filenames );
				if ( $subImport ) {
					$retval['image_ids'] = array_merge( $retval['image_ids'], $subImport['image_ids'] );
				}
			}
		}

		// If no gallery has been specified, then use the directory name as the gallery name.
		if ( ! $gallery_id ) {
			// Create the gallery.
			$gallery = $gallery_mapper->create(
				[
					'title' => $gallery_title ? $gallery_title : I18N::mb_basename( $abspath ),
				]
			);

			if ( ! $create_new_gallerypath ) {
				$gallery_root  = $fs->get_document_root( 'gallery' );
				$gallery->path = str_ireplace( $gallery_root, '', $abspath );
			}

			// Save the gallery.
			if ( $gallery->save() ) {
				$gallery_id = $gallery->id();
			}
		}

		// Ensure that we have a gallery id.
		if ( ! $gallery_id ) {
			return false;
		} else {
			$retval['gallery_id'] = $gallery_id;
		}

		// Remove full sized image if backup is included.
		$files_to_import = [];
		foreach ( $files as $file_abspath ) {

			if ( preg_match( '#_backup$#', $file_abspath ) ) {
				$files_to_import[] = $file_abspath;
				continue;
			} elseif ( in_array( [ $file_abspath . '_backup', 'thumbs_' . $file_abspath, 'thumbs-' . $file_abspath ], $files ) ) {
				continue;
			}

			$files_to_import[] = $file_abspath;
		}

		foreach ( $files_to_import as $file_abspath ) {
			$basename = preg_replace( '#_backup$#', '', pathinfo( $file_abspath, PATHINFO_BASENAME ) );
			if ( $this->is_image_file( $file_abspath ) ) {
				if ( ( $image_id = $this->import_image_file( $gallery_id, $file_abspath, $basename, false, false, false ) ) ) {
					$retval['image_ids'][] = $image_id;
				}
			}
		}

		// Add the gallery name to the result.
		if ( ! isset( $gallery ) ) {
			$gallery = $gallery_mapper->find( $gallery_id );
		}

		$retval['gallery_name'] = $gallery->title;
		return $retval;
	}

	public function maybe_base64_decode( $data ) {
		$decoded = base64_decode( $data );
		if ( $decoded === false ) {
			return $data;
		} elseif ( base64_encode( $decoded ) == $data ) {
			return base64_decode( $data );
		}
		return $data;
	}

	public static function register_custom_post_types() {
		$types = [
			'ngg_album'    => 'NextGEN Gallery - Album',
			'ngg_gallery'  => 'NextGEN Gallery - Gallery',
			'ngg_pictures' => 'NextGEN Gallery - Image',
		];

		foreach ( $types as $type => $label ) {
			\register_post_type(
				$type,
				[
					'label'               => $label,
					'publicly_queryable'  => false,
					'exclude_from_search' => true,
				]
			);
		}
	}

	/**
	 * Sanitizes a directory path, replacing whitespace with dashes.
	 *
	 * Taken from WP' sanitize_file_name() and modified to not act on file extensions.
	 *
	 * Removes special characters that are illegal in filenames on certain
	 * operating systems and special characters requiring special escaping
	 * to manipulate at the command line. Replaces spaces and consecutive
	 * dashes with a single dash. Trims period, dash and underscore from beginning
	 * and end of filename. It is not guaranteed that this function will return a
	 * filename that is allowed to be uploaded.
	 *
	 * @param string $dirname The directory name to be sanitized
	 * @return string The sanitized directory name
	 */
	public function sanitize_directory_name( $dirname ) {
		$dirname_raw   = $dirname;
		$special_chars = [ '?', '[', ']', '/', '\\', '=', '<', '>', ':', ';', ',', "'", '"', '&', '$', '#', '*', '(', ')', '|', '~', '`', '!', '{', '}', '%', '+', chr( 0 ) ];
		$special_chars = apply_filters( 'sanitize_file_name_chars', $special_chars, $dirname_raw );
		$dirname       = preg_replace( "#\x{00a0}#siu", ' ', $dirname );
		$dirname       = str_replace( $special_chars, '', $dirname );
		$dirname       = str_replace( [ '%20', '+' ], '-', $dirname );
		$dirname       = preg_replace( '/[\r\n\t -]+/', '-', $dirname );
		$dirname       = trim( $dirname, '.-_' );
		return $dirname;
	}

	public function sanitize_filename_for_db( $filename = null ) {
		$filename = $filename ? $filename : uniqid( 'nextgen-gallery' );
		$filename = preg_replace( '#^/#', '', $filename );
		$filename = sanitize_file_name( $filename );
		if ( preg_match( '/\-(png|jpg|gif|jpeg|jpg_backup)$/i', $filename, $match ) ) {
			$filename = str_replace( $match[0], '.' . $match[1], $filename );
		}
		return $filename;
	}

	/**
	 * Sets a NGG image as a post thumbnail for the given post
	 *
	 * @param int   $postId
	 * @param Image $image
	 * @param bool  $only_create_attachment
	 * @return int
	 */
	public function set_post_thumbnail( $postId, $image, $only_create_attachment = false ) {
		$retval = false;

		// Get the post ID.
		if ( is_object( $postId ) ) {
			$post   = $postId;
			$postId = isset( $post->ID ) ? $post->ID : $post->post_id;
		}

		// Get the image.
		if ( is_int( $image ) ) {
			$imageId = $image;
			$mapper  = ImageMapper::get_instance();
			$image   = $mapper->find( $imageId );
		}

		if ( $image && $postId ) {
			$attachment_id = $this->is_in_media_library( $image->pid );

			if ( $attachment_id === false ) {
				$attachment_id = $this->copy_to_media_library( $image );
			}

			if ( $attachment_id ) {
				if ( ! $only_create_attachment ) {
					set_post_thumbnail( $postId, $attachment_id );
				}
				$retval = $attachment_id;
			}
		}

		return $retval;
	}

	/**
	 * Uploads an image for a particular gallery
	 *
	 * @param int|object|Gallery $gallery
	 * @param string|bool        $filename (optional) Specifies the name of the file
	 * @param string|bool        $data (optional) If specified, expects base64 encoded string of data
	 *
	 * @return array|array[]|bool|int $image
	 */
	public function upload_image( $gallery, $filename = false, $data = false ) {

		// Ensure that we have the data present that we require.
		//
		// Security::verify_nonce() is a wrapper to wp_verify_nonce().
		//
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		// phpcs:disable WordPress.Security.NonceVerification.Recommended
		if ( isset( $_FILES['file'] )
			&& 0 === $_FILES['file']['error']
			&& isset( $_FILES['file']['tmp_name'] ) ) {
			$file = $_FILES['file'];

			if ( $this->is_zip() ) {
				$retval = $this->upload_zip( $gallery );
			} elseif ( $this->is_image_file() ) {
				$retval = $this->import_image_file(
					$gallery,
					$file['tmp_name'],
					$filename ? $filename : ( isset( $file['name'] ) ? $file['name'] : false ),
					false,
					false,
					true
				);
			} else {
				// Remove the non-valid (and potentially insecure) file from the PHP upload directory.
				if ( isset( $_FILES['file']['tmp_name'] ) ) {
					$filename = $_FILES['file']['tmp_name'];
					@unlink( $filename );
				}
				throw new \E_UploadException( __( 'Invalid image file. Acceptable formats: JPG, GIF, and PNG.', 'nggallery' ) );
			}
		} elseif ( $data ) {
			$retval = $this->upload_base64_image(
				$gallery,
				$data,
				$filename
			);
		} else {
			throw new \E_UploadException();
		}

		// phpcs:enable WordPress.Security.NonceVerification.Missing
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		return $retval;
	}

	/**
	 * Uploads base64 file to a gallery
	 *
	 * @param int|\stdClass|Gallery   $gallery
	 * @param string                  $data base64-encoded string of data representing the image
	 * @param string|false (optional) $filename specifies the name of the file
	 * @param int|false               $image_id (optional)
	 * @param bool                    $override (optional)
	 *
	 * @return bool|int
	 */
	public function upload_base64_image( $gallery, $data, $filename = false, $image_id = false, $override = false, $move = false ) {
		$temp_abspath = tempnam( sys_get_temp_dir(), '' );

		// Try writing the image.
		$fp = fopen( $temp_abspath, 'wb' );
		fwrite( $fp, $this->maybe_base64_decode( $data ) );
		fclose( $fp );

		return $this->import_image_file( $gallery, $temp_abspath, $filename, $image_id, $override, $move );
	}

	/**
	 * @param int $gallery_id
	 * @param bool $skip_nonce_check Whether to skip nonce verification (true for REST API calls)
	 * @return array|bool
	 */
	public function upload_zip( $gallery_id, $skip_nonce_check = false ) {
		if ( ! $this->is_zip( $skip_nonce_check ) ) {
			return false;
		}

		// Skip nonce check for REST API calls (they have their own authentication via permission callbacks)
		// For traditional form posts, verify the nonce
		if ( ! $skip_nonce_check ) {
			// Security::verify_nonce() is a wrapper to wp_verify_nonce().
			//
			// phpcs:disable WordPress.Security.NonceVerification.Missing
			// phpcs:disable WordPress.Security.NonceVerification.Recommended
			if ( ! isset( $_REQUEST['nonce'] )
				|| ! Security::verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ), 'nextgen_upload_image' ) ) {
				return false;
			}
			// phpcs:enable WordPress.Security.NonceVerification.Missing
			// phpcs:enable WordPress.Security.NonceVerification.Recommended
		}

		$retval = false;

		$memory_limit = intval( ini_get( 'memory_limit' ) );
		if ( ! extension_loaded( 'suhosin' ) && $memory_limit < 256 ) {
			@ini_set( 'memory_limit', '256M' );
		}

		$fs = Filesystem::get_instance();

		// Uses the WordPress ZIP abstraction API.
		include_once $fs->join_paths( ABSPATH, 'wp-admin', 'includes', 'file.php' );
		WP_Filesystem( false, get_temp_dir(), true );

		// Ensure that we truly have the gallery id.
		$gallery_id = $this->get_gallery_id( $gallery_id );

		// The nonce was already checked above, by Security::verify_nonce(). Also PHP-CS still flags this particular
		// line when using phpcs:ignore, thus the disable/enable pairing found here.
		//
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$zipfile = $_FILES['file']['tmp_name'];
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$dest_path = implode(
			DIRECTORY_SEPARATOR,
			[
				rtrim( get_temp_dir(), '/\\' ),
				'unpacked-' . I18N::mb_basename( $zipfile ),
			]
		);

		// Attempt to extract the zip file into the normal system directory.
		$extracted = $this->extract_zip( $zipfile, $dest_path );

		// Now verify it worked. get_temp_dir() will check each of the following directories to ensure they are
		// a directory and against wp_is_writable(). Should ALL of those options fail we will fallback to wp_upload_dir().
		$size  = 0;
		$files = glob( $dest_path . DIRECTORY_SEPARATOR . '*' );
		foreach ( $files as $file ) {
			if ( is_array( stat( $file ) ) ) {
				$size += filesize( $file );
			}
		}

		// Extraction failed; attempt again with wp_upload_dir().
		if ( $size == 0 ) {
			// Remove the empty directory we may have possibly created but could not write to.
			$this->delete_directory( $dest_path );

			$destination      = wp_upload_dir();
			$destination_path = $destination['basedir'];
			$dest_path        = implode(
				DIRECTORY_SEPARATOR,
				[
					rtrim( $destination_path, '/\\' ),
					rand(),
					'unpacked-' . I18N::mb_basename( $zipfile ),
				]
			);

			$extracted = $this->extract_zip( $zipfile, $dest_path );
		}

		if ( $extracted ) {
			$retval = $this->import_gallery_from_fs( $dest_path, $gallery_id );
		}

		$this->delete_directory( $dest_path );

		if ( ! extension_loaded( 'suhosin' ) ) {
			@ini_set( 'memory_limit', $memory_limit . 'M' );
		}

		return $retval;
	}

	public static function wp_query_order_by( $order_by, $wp_query ) {
		if ( $wp_query->get( 'datamapper_attachment' ) ) {
			$order_parts = explode( ' ', $order_by );
			$order_name  = array_shift( $order_parts );
			$order_by    = 'ABS(' . $order_name . ') ' . implode( ' ', $order_parts ) . ', ' . $order_by;
		}

		return $order_by;
	}


	/**
	 * Gets the id of an image, regardless of whether an integer or object was passed as an argument.
	 *
	 * This method is, as of 3.50's release, used by EWWW and WP-SmushIt
	 *
	 * @param object|int $image_obj_or_id
	 * @return null|int
	 * @deprecated
	 */
	function _get_image_id( $image_obj_or_id ) {
		$retval = null;

		$image_key = $this->_image_mapper->get_primary_key_column();
		if ( is_object( $image_obj_or_id ) ) {
			if ( isset( $image_obj_or_id->$image_key ) ) {
				$retval = $image_obj_or_id->$image_key;
			}
		} elseif ( is_numeric( $image_obj_or_id ) ) {
			$retval = $image_obj_or_id;
		}

		return $retval;
	}
}
