<?php
/**
 * Directory Smush: Dir class
 *
 * @package Smush\Core\Modules
 * @since 2.6
 *
 * @author Umesh Kumar <umesh@incsub.com>
 *
 * @copyright (c) 2016, Incsub (http://incsub.com)
 */

namespace Smush\Core\Modules;

use Exception;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Smush\Core\Bulk\Bulk_Optimize;
use Smush\Core\Core;
use Smush\Core\Installer;
use Smush\Core\Membership\Membership;
use Smush\Core\Optimizer;
use Smush\Core\Settings;
use Smush\Core\Helper;
use Smush\Core\Smush\Dir_Smusher_Options_Provider;
use WP_Error;
use WP_Smush;

if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * Class Dir
 */
class Dir extends Abstract_Module {
	/**
	 * Contains a list of optimised images.
	 *
	 * @var $optimised_images
	 */
	public $optimised_images;

	/**
	 * Flag to check if dir smush table exist.
	 *
	 * @var $table_exist
	 */
	public static $table_exist;

	/**
	 * Total Stats for the image optimisation.
	 *
	 * @var $stats
	 */
	public $stats;

	/**
	 * Directory scanner.
	 *
	 * @var Helpers\DScanner
	 */
	public $scanner;
	private $membership;

	/**
	 * Dir constructor.
	 */
	public function init() {
		// We only run in admin.
		if ( ! is_admin() ) {
			return;
		}

		add_filter( 'wp_smush_localize_ui_script_data', array( $this, 'localize_dir_script_data' ) );
		add_filter( 'wp_smush_sync_settings', array( $this, 'handle_settings_sync' ), 10, 3 );

		$this->membership = Membership::get_instance();

		/**
		 * Handle Ajax request 'smush_get_directory_list'.
		 *
		 * This needs to be before self::should_continue so that the request from network admin is processed.
		 */
		if ( wp_doing_ajax() ) {
			if ( ! $this->scanner ) {
				$this->scanner = new Helpers\DScanner();
			}

			add_action( 'wp_ajax_smush_get_directory_list', array( $this, 'directory_list' ) );

			// Scan the given directory path for the list of images.
			add_action( 'wp_ajax_image_list', array( $this, 'image_list' ) );

			/**
			 * Scanner ajax actions.
			 *
			 * @since 2.8.1
			 */
			add_action( 'wp_ajax_directory_smush_start', array( $this, 'directory_smush_start' ) );
			add_action( 'wp_ajax_directory_smush_check_step', array( $this, 'directory_smush_check_step' ) );
			add_action( 'wp_ajax_directory_smush_finish', array( $this, 'directory_smush_finish' ) );
			add_action( 'wp_ajax_directory_smush_cancel', array( $this, 'directory_smush_cancel' ) );
		}

		add_action( 'current_screen', array( $this, 'initialize' ), 10 );
	}

	/**
	 * To get access to get_current_screen(), we need to move this under the current_screen action.
	 *
	 * @since 2.8.0
	 */
	public function initialize() {
		$current_page = '';
		if ( function_exists( 'get_current_screen' ) ) {
			$current_screen = get_current_screen();
			$current_page   = ! empty( $current_screen ) ? $current_screen->base : '';
		}

		if ( false === strpos( $current_page, 'page_smush-bulk' ) ) {
			return;
		}

		if ( ! self::should_continue() ) {
			// Remove directory smush from tabs if not required.
			add_filter( 'smush_setting_tabs', array( $this, 'remove_directory_tab' ) );

			return;
		}

		if ( ! $this->scanner ) {
			$this->scanner = new Helpers\DScanner();
		}

		if ( ! $this->scanner->is_scanning() ) {
			$this->scanner->reset_scan();
		}

		// Check and show missing directory smush table error.
		add_action( 'wp_smush_header_notices', array( $this, 'show_table_error' ) );

		// Check directory smush table after screen is set.
		add_action( 'current_screen', array( $this, 'check_table' ) );

		// Check to see if the scanner should be running.
		add_action( 'admin_footer', array( $this, 'check_scan' ) );
	}

	/**
	 * Do not display Directory smush for subsites.
	 *
	 * @return bool True/False, whether to display the Directory smush or not
	 */
	public static function should_continue() {
		if ( Settings::is_ajax_network_admin() ) {
			return true;
		}

		// Do not show directory smush, if not main site in a network.
		if ( is_multisite() && ( ! is_main_site() || ! is_network_admin() ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Run the scanner on page refresh (if it's running).
	 *
	 * @since 2.8.1
	 */
	public function check_scan() {
		if ( $this->scanner->is_scanning() ) {
			?>
			<script>
				jQuery( document ).ready( function() {
					jQuery('#wp-smush-progress-dialog').show();
					window.WP_Smush.directory.scanner.scan();
				});
			</script>
			<?php
		}
	}

	/**
	 * Directory Smush: Start smush.
	 *
	 * @since 2.8.1
	 */
	public function directory_smush_start() {
		check_ajax_referer( 'wp-smush-ajax' );
		// Check for permission.
		if ( ! Helper::is_user_allowed( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'wp-smushit' ), 403 );
		}
		$this->scanner->init_scan();
		do_action( 'wp_smush_directory_smush_start' );
		wp_send_json_success();
	}

	/**
	 * Directory Smush: Smush step.
	 *
	 * @since 2.8.1
	 */
	public function directory_smush_check_step() {
		check_ajax_referer( 'wp-smush-ajax' );

		// Check for permission.
		if ( ! Helper::is_user_allowed( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'wp-smushit' ), 403 );
		}

		$urls         = $this->get_scanned_images();
		$current_step = isset( $_POST['step'] ) ? absint( $_POST['step'] ) : 0;

		$this->scanner->update_current_step( $current_step );

		if ( isset( $urls[ $current_step ] ) ) {
			$this->optimise_image( (int) $urls[ $current_step ]['id'] );
		}

		wp_send_json_success();
	}

	/**
	 * Directory Smush: Finish smush.
	 *
	 * @since 2.8.1
	 */
	public function directory_smush_finish() {
		check_ajax_referer( 'wp-smush-ajax' );

		// Check for permission.
		if ( ! Helper::is_user_allowed( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'wp-smushit' ), 403 );
		}

		$items   = isset( $_POST['items'] ) ? absint( $_POST['items'] ) : 0;
		$failed  = isset( $_POST['failed'] ) ? absint( $_POST['failed'] ) : 0;
		$skipped = isset( $_POST['skipped'] ) ? absint( $_POST['skipped'] ) : 0;

		// If any images failed to smush, store count.
		if ( $failed > 0 ) {
			set_transient( 'wp-smush-dir-scan-failed-items', $failed, 60 * 5 ); // 5 minutes max.
		}

		if ( $skipped > 0 ) {
			set_transient( 'wp-smush-dir-scan-skipped-items', $skipped, 60 * 5 ); // 5 minutes max.
		}

		// Store optimized items count.
		set_transient( 'wp-smush-show-dir-scan-notice', $items, 60 * 5 ); // 5 minutes max.
		$this->scanner->reset_scan();
		wp_send_json_success();
	}

	/**
	 * Directory Smush: Cancel smush.
	 *
	 * @since 2.8.1
	 */
	public function directory_smush_cancel() {
		check_ajax_referer( 'wp-smush-ajax' );
		// Check for permission.
		if ( ! Helper::is_user_allowed( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'wp-smushit' ), 403 );
		}
		$this->scanner->reset_scan();
		wp_send_json_success();
	}

	/**
	 * Handles the ajax request for image optimisation in a folder
	 *
	 * @param int $id  Image ID.
	 */
	private function optimise_image( $id ) {
		global $wpdb;

		$error_msg = '';

		// No image ID.
		if ( $id < 1 ) {
			$error_msg = esc_html__( 'Incorrect image id', 'wp-smushit' );
			wp_send_json_error( $error_msg );
		}

		$scanned_images = $this->get_unsmushed_images();
		$image          = $this->get_image( $id, '', $scanned_images );

		if ( empty( $image ) ) {
			wp_send_json_success( array( 'skipped' => true ) );
		}

		$path = $image['path'];

		if ( false !== stripos( $path, 'phar://' ) ) {
			wp_send_json_error(
				array(
					'error' => esc_html__( 'Potential Phar PHP Object Injection detected', 'wp-smushit' ),
					'image' => array(
						'id' => $id,
					),
				)
			);
		}

		// We have the image path, optimise.
		$dir_smusher_options = ( new Dir_Smusher_Options_Provider() )->get_options();
		$results             = Optimizer::get_instance()->optimize_file( $path, false, $dir_smusher_options );

		if ( is_wp_error( $results ) ) {
			/**
			 * Smush results.
			 *
			 * @var WP_Error $results
			 */
			$error_msg = $results->get_error_message();
		} elseif ( empty( $results['data'] ) ) {
			// If there are no stats.
			$error_msg = esc_html__( "Image couldn't be optimized", 'wp-smushit' );
		}

		if ( ! empty( $error_msg ) ) {
			// Store the error in DB. All good, Update the stats.
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->base_prefix}smush_dir_images SET error=%s WHERE id=%d LIMIT 1",
					$error_msg,
					$id
				)
			); // Db call ok; no-cache ok.

			wp_send_json_error(
				array(
					'error' => $error_msg,
					'image' => array(
						'id' => $id,
					),
				)
			);
		}

		if ( ! $this->settings ) {
			$this->settings = Settings::get_instance();
		}

		// All good, Update the stats.
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->base_prefix}smush_dir_images SET error=NULL, image_size=%d, file_time=%d, lossy=%d, meta=%d WHERE id=%d LIMIT 1",
				$results['data']->after_size,
				filectime( $path ), // Get file time.
				$this->settings->get_dir_lossy_level_setting(),
				$this->settings->get( 'dir_strip_exif' ),
				$id
			)
		); // Db call ok; no-cache ok.
	}

	/**
	 * Create the Smush image table to store the paths of scanned images, and stats
	 */
	public function create_table() {
		// If table is already created, returns.
		if ( self::table_exist() ) {
			return;
		}
		global $wpdb;

		$charset_collate = $wpdb->get_charset_collate();

		/**
		 * Table: wp_smush_dir_images
		 * Columns:
		 * id         -> Auto Increment ID
		 * path       -> Absolute path to the image file
		 * resize     -> Whether the image was resized or not
		 * lossy      -> Whether the image was super-smushed/lossy or not
		 * image_size -> Current image size post optimisation
		 * orig_size  -> Original image size before optimisation
		 * file_time  -> Unix time for the file creation, to match it against the current creation time,
		 *                  in order to confirm if it is optimised or not
		 * last_scan  -> Timestamp, Get images form last scan by latest timestamp
		 *                  are from latest scan only and not the whole list from db
		 * meta       -> For any future use
		 */
		$sql = "CREATE TABLE IF NOT EXISTS {$wpdb->base_prefix}smush_dir_images (
			id mediumint(9) UNSIGNED NOT NULL AUTO_INCREMENT,
			path text NOT NULL,
			path_hash CHAR(32),
			resize varchar(55),
			lossy varchar(55),
			error varchar(55) DEFAULT NULL,
			image_size int(10) unsigned,
			orig_size int(10) unsigned,
			file_time int(10) unsigned,
			last_scan timestamp DEFAULT '0000-00-00 00:00:00',
			meta text,
  			PRIMARY KEY (id),
			UNIQUE KEY path_hash (path_hash),
			KEY image_size (image_size)
		) $charset_collate;";

		// Include the upgrade library to initialize a table.
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		/**
		 * PHP 8.1 trigger an error when calling query "DESCRIBE {$table};" if the table doesn't exists.
		 * Since we only create table when it doesn't exists, so don't need to use dbDelta for this case.
		 */
		// Hide errors.
		$wpdb->hide_errors();
		// Create table.
		$wpdb->query( $sql );

		// Set flag.
		self::$table_exist = self::table_exist( true );
	}

	/**
	 * Get the image ids and path for last scanned images
	 *
	 * @return array Array of last scanned images containing image id and path
	 */
	public function get_scanned_images() {
		global $wpdb;

		$results = $wpdb->get_results( "SELECT id, path, orig_size FROM {$wpdb->base_prefix}smush_dir_images WHERE last_scan = (SELECT MAX(last_scan) FROM {$wpdb->base_prefix}smush_dir_images )  GROUP BY id ORDER BY id", ARRAY_A ); // Db call ok; no-cache ok.

		// Return image ids.
		if ( is_wp_error( $results ) ) {
			Helper::logger()->dir()->error( sprintf( 'Query error: %s', $results->get_error_message() ) );
			$results = array();
		}

		return $results;
	}

	/**
	 * Get only images that need compressing.
	 *
	 * @since 3.6.1
	 *
	 * @return array Array of images that require compression.
	 */
	public function get_unsmushed_images() {
		global $wpdb;

		$condition   = 'image_size IS NULL';
		$lossy_level = $this->settings->get_dir_lossy_level_setting();
		if ( $lossy_level > 0 ) {
			$condition .= ' OR lossy IS NULL OR lossy < ' . intval( $lossy_level );
		}

		if ( $this->settings->get( 'dir_strip_exif' ) ) {
			$condition .= ' OR meta <> 1';
		}

		$results = $wpdb->get_results( "SELECT id, path, orig_size FROM {$wpdb->base_prefix}smush_dir_images WHERE {$condition} && last_scan = (SELECT MAX(last_scan) FROM {$wpdb->base_prefix}smush_dir_images )  GROUP BY id ORDER BY id", ARRAY_A ); // Db call ok; no-cache ok.

		// Return image ids.
		if ( is_wp_error( $results ) ) {
			Helper::logger()->dir()->error( sprintf( 'Query error: %s', $results->get_error_message() ) );
			$results = array();
		}

		return $results;
	}

	/**
	 * Get the paths and errors from last scan.
	 *
	 * @since 3.0
	 *
	 * @param int $limit  Limit the number of results.
	 *
	 * @return array  Array of last scanned images
	 */
	public function get_image_errors( $limit = 50 ) {
		global $wpdb;

		return $wpdb->get_results(
			"SELECT id, path, error
					FROM {$wpdb->base_prefix}smush_dir_images
					WHERE error IS NOT NULL
						AND last_scan = ( SELECT MAX(last_scan) FROM {$wpdb->base_prefix}smush_dir_images )
					LIMIT $limit",
			ARRAY_A
		); // Db call ok; no-cache ok.
	}

	/**
	 * Return the number of errors.
	 *
	 * @since 3.0
	 *
	 * @return int
	 */
	public function get_image_errors_count() {
		global $wpdb;

		return (int) $wpdb->get_var(
			"SELECT COUNT(id)
					FROM {$wpdb->base_prefix}smush_dir_images
					WHERE error IS NOT NULL AND last_scan = ( SELECT MAX(last_scan) FROM {$wpdb->base_prefix}smush_dir_images )"
		); // Db call ok.
	}

	/**
	 * Check if the image file is media library file
	 *
	 * @param string $file_path  File path.
	 *
	 * @return bool
	 */
	private function is_media_library_file( $file_path ) {
		$upload_dir  = wp_upload_dir();
		$upload_path = $upload_dir['path'];

		// Get the base path of file.
		$base_dir = dirname( $file_path );
		if ( $base_dir === $upload_path ) {
			return true;
		}

		return false;
	}

	/**
	 * Return a directory/File list
	 *
	 * PHP Connector
	 */
	public function directory_list() {
		// Check For permission.
		if ( ! Helper::is_user_allowed( 'manage_options' ) || ! is_user_logged_in() ) {
			Helper::logger()->dir()->error( 'Unauthorized - Permission access.' );
			wp_send_json_error( __( 'Unauthorized', 'wp-smushit' ) );
		}

		// Verify nonce.
		check_ajax_referer( 'wp-smush-ajax', '_ajax_nonce' );

		$dir  = filter_input( INPUT_GET, 'dir', FILTER_SANITIZE_SPECIAL_CHARS );
		$tree = $this->get_directory_tree( $dir );

		if ( ! is_array( $tree ) ) {
			Helper::logger()->dir()->error( 'Unauthorized - Directory empty.' );
			wp_send_json_error( __( 'Unauthorized', 'wp-smushit' ) );
		}

		wp_send_json( $tree );
	}

	public function get_directory_list() {
		if ( ! Helper::is_user_allowed( 'manage_options' ) || ! is_user_logged_in() ) {
			Helper::logger()->dir()->error( 'Unauthorized - Permission access.' );
			return new WP_Error( 'unauthorized', __( 'Unauthorized', 'wp-smushit' ) );
		}

		$tree = $this->get_directory_tree();

		if ( ! is_array( $tree ) ) {
			Helper::logger()->dir()->error( 'Unauthorized - Directory empty.' );
			return new WP_Error( 'unauthorized', __( 'Unauthorized', 'wp-smushit' ) );
		}

		return $tree;
	}

	/**
	 * Gets the directory tree data for the given path.
	 *
	 * @since 3.8.3
	 *
	 * @param string $dir Directory path.
	 * @return array|bool False on failure. Array with the data on success.
	 */
	private function get_directory_tree( $dir = null ) {
		// Get the root path for a main site or subsite.
		$root = realpath( $this->get_root_path() );

		// PHP 8.1 strlen doesn't accept null.
		if ( ! is_null( $dir ) && strlen( $dir ) >= 1 ) {
			$post_dir = realpath( path_join( $root, $dir ) );
		} else {
			$post_dir = $root;
		}

		// If the final path doesn't contain the root path, bail out.
		if ( ! $root || false === $post_dir || 0 !== strpos( $post_dir, $root ) ) {
			return false;
		}

		$supported_image = array(
			'gif',
			'jpg',
			'jpeg',
			'png',
		);

		if ( file_exists( $post_dir ) && is_dir( $post_dir ) ) {
			$files = scandir( $post_dir );
			// Exclude hidden files.
			if ( ! empty( $files ) ) {
				$files = preg_grep( '/^([^.])/', $files );
			}
			$return_dir = substr( $post_dir, strlen( $root ) );

			natcasesort( $files );

			if ( count( $files ) !== 0 && ! $this->skip_dir( $post_dir ) ) {
				$tree = array();

				foreach ( $files as $file ) {
					$html_rel  = htmlentities( ltrim( path_join( $return_dir, $file ), '/' ) );
					$html_name = htmlentities( $file );
					$ext       = preg_replace( '/^.*\./', '', $file );

					$file_path = path_join( $post_dir, $file );
					if ( ! file_exists( $file_path ) || '.' === $file || '..' === $file ) {
						continue;
					}

					// Skip unsupported files and files that are already in the media library.
					if ( ! is_dir( $file_path ) && ( ! in_array( $ext, $supported_image, true ) || $this->is_media_library_file( $file_path ) ) ) {
						continue;
					}

					$skip_path = $this->skip_dir( $file_path );

					$tree[] = array(
						'title'        => $html_name,
						'key'          => $html_rel,
						'folder'       => is_dir( $file_path ),
						'lazy'         => ! $skip_path,
						'checkbox'     => true,
						'unselectable' => $skip_path, // Skip Uploads folder - Media Files.
					);
				}

				return $tree;
			}
		}

		return array();
	}

	/**
	 * Get root path of the installation.
	 *
	 * @return string Root path.
	 */
	public function get_root_path() {
		// If main site.
		if ( is_super_admin() ) {
			/**
			 * Sometimes content directories may reside outside
			 * the installation sub-directory. We need to make sure
			 * we are selecting the root directory, not installation
			 * directory.
			 *
			 * @see https://xnau.com/finding-the-wordpress-root-path-for-an-alternate-directory-structure/
			 * @see https://app.asana.com/0/14491813218786/487682361460247/f
			 */
			$content_path = explode( '/', wp_normalize_path( WP_CONTENT_DIR ) );
			// Get root path and explode.
			$root_path = explode( '/', get_home_path() );

			// Find the length of the shortest one.
			$end         = min( count( $content_path ), count( $root_path ) );
			$i           = 0;
			$common_path = array();
			// Add the component if they are the same in both paths.
			while ( $content_path[ $i ] === $root_path[ $i ] && $i < $end ) {
				$common_path[] = $content_path[ $i ];
				$i++;
			}

			$is_valid_path = count( $common_path ) > 1 || ! empty( $common_path[0] );
			if ( $is_valid_path ) {
				return implode( '/', $common_path );
			}

			// If couldn't detect the root folder, it seems there is custom directory structure, e.g Flywheel.
			// Let's try to use parent folder of WP_CONTENT_DIR.
			return dirname( wp_normalize_path( WP_CONTENT_DIR ) );
		}

		$up = wp_upload_dir();
		return $up['basedir'];
	}

	/**
	 * Checks whether the user-submitted paths are among our allowed ones.
	 *
	 * @since 3.8.3
	 *
	 * @param string $path_to_check User-submitted path.
	 * @return bool
	 */
	private function validate_path( $path_to_check ) {
		$is_valid = true;

		// Verify that every directory in the path is allowed.
		while ( $is_valid && dirname( $path_to_check ) !== $path_to_check ) {
			$path_contents = $this->get_directory_tree( $path_to_check );

			if ( empty( $path_contents ) ) {
				return false;
			}

			$is_valid = false;
			foreach ( $path_contents as $tree_data ) {
				if ( false !== strpos( $tree_data['key'], $path_to_check ) && ! $tree_data['unselectable'] ) {
					$is_valid = true;
					break;
				}
			}

			if ( ! $is_valid ) {
				$path_to_check = dirname( $path_to_check );
			} else {
				// Valid path, break out of the loop.
				break;
			}
		}

		return $is_valid;
	}

	/**
	 * Get the image list in a specified directory path.
	 *
	 * @since 2.8.1  Added support for selecting files.
	 *
	 * @param string|array $paths  Path where to look for images, or selected images.
	 *
	 * @throws Exception Never, actually. Supposedly, when an invalid directory was selected.
	 * @return array
	 */
	private function get_image_list( $paths = '' ) {
		// Error with directory tree.
		if ( ! is_array( $paths ) ) {
			$this->send_error( __( 'There was a problem getting the selected directories', 'wp-smushit' ) );
		}

		$count     = 0;
		$images    = array();
		$values    = array();
		$timestamp = gmdate( 'Y-m-d H:i:s' );

		// Temporary increase the limit.
		wp_raise_memory_limit( 'image' );

		// Avoid checking already validated paths.
		$validated_dirs = array();
		$root_path      = $this->get_root_path();

		// Iterate over all the selected items (can be either an image or directory).
		foreach ( $paths as $relative_path ) {
			if ( ! is_string( $relative_path ) ) {
				continue;
			}

			// Make the path absolute.
			$path = trim( $root_path . '/' . $relative_path );

			// Prevent phar deserialization vulnerability.
			if ( stripos( $path, 'phar://' ) !== false ) {
				continue;
			}

			/**
			 * Path is an image.
			 *
			 * TODO: The is_dir() check fails directories with spaces.
			 */
			if ( ! is_dir( $path ) && ! $this->is_media_library_file( $path ) && ! strpos( $path, '.bak' ) ) {
				if ( ! $this->is_image( $path ) ) {
					continue;
				}

				// Image already added. Skip.
				if ( in_array( $path, $images, true ) ) {
					continue;
				}

				// Skip if the parent directory isn't allowed.
				if ( ! in_array( dirname( $relative_path ), $validated_dirs, true ) ) {
					if ( ! $this->validate_path( dirname( $relative_path ) ) ) {
						continue;
					}
					$validated_dirs[] = dirname( $relative_path );
				}

				$images[] = $path;
				$images[] = md5( $path );
				$images[] = filesize( $path );  // Get the file size.
				$images[] = filectime( $path ); // Get the file modification time.
				$images[] = $timestamp;
				$values[] = '(%s, %s, %d, %d, %s)';
				$count++;

				// Store the images in db at an interval of 5k.
				if ( $count >= 5000 ) {
					$count = 0;
					$this->store_images( $values, $images );
					$images = $values = array();
				}

				continue;
			}

			/**
			 * Path is a directory.
			 */
			$base_dir = realpath( rawurldecode( $path ) );

			if ( ! $base_dir ) {
				$this->send_error( __( 'Unauthorized', 'wp-smushit' ) );
			}

			// Skip if this directory isn't allowed.
			if ( ! in_array( $relative_path, $validated_dirs, true ) ) {
				if ( ! $this->validate_path( $relative_path ) ) {
					continue;
				}
				$validated_dirs[] = $relative_path;
			}

			// Directory Iterator, Exclude . and ..
			$filtered_dir = new Helpers\Iterator( new RecursiveDirectoryIterator( $base_dir ) );

			// File Iterator.
			$iterator = new RecursiveIteratorIterator( $filtered_dir, RecursiveIteratorIterator::CHILD_FIRST );

			foreach ( $iterator as $file ) {
				// Used in place of Skip Dots, For php 5.2 compatibility.
				if ( basename( $file ) === '..' || basename( $file ) === '.' ) {
					continue;
				}

				// Not a file. Skip.
				if ( ! $file->isFile() ) {
					continue;
				}

				$file_path = $file->getPathname();

				if ( $this->is_image( $file_path ) && ! $this->is_media_library_file( $file_path ) && strpos( $file, '.bak' ) === false ) {
					/** To be stored in DB, Part of code inspired from Ewwww Optimiser  */
					$images[] = $file_path;
					$images[] = md5( $file_path );
					$images[] = $file->getSize();
					$images[] = filectime( $file_path ); // Get the file modification time.
					$images[] = $timestamp;
					$values[] = '(%s, %s, %d, %d, %s)';
					$count++;
				}

				// Store the images in db at an interval of 5k.
				if ( $count >= 5000 ) {
					$count = 0;
					$this->store_images( $values, $images );
					$images = array();
					$values = array();
				}
			}
		}

		if ( empty( $images ) || 0 === $count ) {
			return array();
		}

		// Update rest of the images.
		$this->store_images( $values, $images );

		// Get the image ids.
		return $this->get_scanned_images();
	}

	/**
	 * Write to the database.
	 *
	 * @since 2.8.1
	 *
	 * @param array $values  Values for query build.
	 * @param array $images  Array of images.
	 */
	private function store_images( $values, $images ) {
		global $wpdb;

		$query = $this->build_query( $values, $images );
		$wpdb->query( $query ); // Db call ok; no-cache ok.
	}

	/**
	 * Build and prepare query from the given values and image array.
	 *
	 * @param array $values  Values.
	 * @param array $images  Images.
	 *
	 * @return bool|string
	 */
	private function build_query( $values, $images ) {
		if ( empty( $images ) || empty( $values ) ) {
			return false;
		}

		global $wpdb;
		$values = implode( ',', $values );

		// Replace with image path and respective parameters.
		$query = "INSERT INTO {$wpdb->base_prefix}smush_dir_images (path, path_hash, orig_size, file_time, last_scan) VALUES $values ON DUPLICATE KEY UPDATE image_size = IF( file_time < VALUES(file_time), NULL, image_size ), file_time = IF( file_time < VALUES(file_time), VALUES(file_time), file_time ), last_scan = VALUES( last_scan )";
		return $wpdb->prepare( $query, $images ); // Db call ok; no-cache ok.
	}

	/**
	 * Sends a Ajax response with a message to be shown in a floating warning notice.
	 *
	 * @param string $message The message for the notice.
	 */
	private function send_error( $message ) {
		wp_send_json_error(
			array(
				'message' => sprintf( '<p>%s</p>', esc_html( $message ) ),
			)
		);
	}

	/**
	 * Handles Ajax request to obtain the Image list within a selected directory path
	 */
	public function image_list() {
		// Check For permission.
		if ( ! current_user_can( 'manage_options' ) ) {
			$this->send_error( __( 'Unauthorized', 'wp-smushit' ) );
		}

		// Verify nonce.
		check_ajax_referer( 'wp-smush-ajax', '_ajax_nonce' );

		// Check if directory path is set or not.
		if ( empty( $_POST['smush_path'] ) ) { // Input var ok.
			$this->send_error( __( 'Empty Directory Path', 'wp-smushit' ) );
		}

		// FILTER_SANITIZE_URL is trimming the space if a folder contains space.
		$smush_path = filter_input( INPUT_POST, 'smush_path', FILTER_SANITIZE_SPECIAL_CHARS, FILTER_REQUIRE_ARRAY );

		try {
			// This will add the images to the database and get the file list.
			$files = $this->get_image_list( $smush_path );
		} catch ( Exception $e ) {
			$this->send_error( $e->getMessage() );
		}

		// If files array is empty, send a message.
		if ( empty( $files ) ) {
			$this->send_error( __( 'We could not find any images in the selected directory.', 'wp-smushit' ) );
		}

		// Clear cache.
		wp_cache_delete( 'wp-smush-dir_total_stats', 'wp-smush' );

		// Send response.
		wp_send_json_success( count( $files ) );
	}

	/**
	 * Check whether the given path is a image or not.
	 *
	 * Do not include backup files.
	 *
	 * @param string $path  Image path.
	 *
	 * @return bool
	 */
	private function is_image( $path ) {
		// Check if the path is valid.
		if ( ! file_exists( $path ) || ! $this->is_image_from_extension( $path ) ) {
			return false;
		}

		if ( false !== stripos( $path, 'phar://' ) ) {
			return false;
		}

		$a = wp_getimagesize( $path );

		// If $a is not set.
		if ( empty( $a ) ) {
			return false;
		}

		$image_type = $a[2];

		if ( in_array( $image_type, array( IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG ) ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Obtain the path to the admin directory.
	 *
	 * @return string
	 *
	 * Thanks @andrezrv (Github)
	 * TODO: this does not properly get the admin path in Bedrock
	 */
	private function get_admin_path() {
		// Replace the site base URL with the absolute path to its installation directory.
		$admin_path = rtrim( str_replace( get_bloginfo( 'url' ) . '/', ABSPATH, get_admin_url() ), '/' );

		// Make it filterable, so other plugins can hook into it.
		return apply_filters( 'wp_smush_get_admin_path', $admin_path );
	}

	/**
	 * Check if the given file path is a supported image format
	 *
	 * @param string $path  File path.
	 *
	 * @return bool Whether an image or not
	 */
	private function is_image_from_extension( $path ) {
		$supported_image = array( 'gif', 'jpg', 'jpeg', 'png' );
		$ext             = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); // Using strtolower to overcome case sensitive.

		if ( in_array( $ext, $supported_image, true ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Excludes the Media Upload Directory ( Checks for Year and Month ).
	 *
	 * Borrowed from Shortpixel - (y)*
	 * TODO: Add a option to filter images if User have turned off the Year and Month Organize option
	 *
	 * @param string $path  Path.
	 *
	 * @return bool
	 */
	public function skip_dir( $path ) {
		// Admin directory path.
		$admin_dir = $this->get_admin_path();

		// Includes directory path.
		$includes_dir = ABSPATH . WPINC;

		// Upload directory.
		$upload_dir = wp_upload_dir();
		$base_dir   = $upload_dir['basedir'];

		$skip = false;

		// Don't skip the whole sites folder but only skip media upload year folder for multi-sites.
		if ( false !== strpos( $path, $base_dir . '/sites' ) ) {
			// If matches the current upload path contains one of the year sub folders of the media library.
			$path_arr = explode( '/', str_replace( $base_dir . '/sites/', '', $path ) );
			if ( is_array( $path_arr ) && count( $path_arr ) > 1
				&& is_numeric( $path_arr[1] ) && $path_arr[1] > 1900 && $path_arr[1] < 2100 // Contains the year sub folder.
			) {
				$skip = true;
			}
		} elseif ( false !== strpos( $path, $base_dir ) ) {
			// If matches the current upload path contains one of the year sub folders of the media library.
			$path_arr = explode( '/', str_replace( $base_dir . '/', '', $path ) );
			if ( count( $path_arr ) >= 1
				&& is_numeric( $path_arr[0] ) && $path_arr[0] > 1900 && $path_arr[0] < 2100 // Contains the year sub folder.
				&& ( 1 === count( $path_arr ) // If there is another sub folder then it's the month sub folder.
				|| ( is_numeric( $path_arr[1] ) && $path_arr[1] > 0 && $path_arr[1] < 13 ) )
			) {
				$skip = true;
			}
		} elseif ( ( false !== strpos( $path, $admin_dir ) ) || false !== strpos( $path, $includes_dir ) ) {
			$skip = true;
		}

		// Can be used to skip/include folders matching a specific directory path.
		return apply_filters( 'wp_smush_skip_folder', $skip, $path );
	}

	/**
	 * Search for image from given image id or path.
	 *
	 * @param string $id      Image id to search for.
	 * @param string $path    Image path to search for.
	 * @param array  $images  Image array to search within.
	 *
	 * @return array  Image array or empty array.
	 */
	private function get_image( $id, $path, $images ) {
		foreach ( $images as $key => $val ) {
			if ( ! empty( $id ) && (int) $val['id'] === $id ) {
				return $images[ $key ];
			} elseif ( ! empty( $path ) && $val['path'] === $path ) {
				return $images[ $key ];
			}
		}

		return array();
	}

	/**
	 * Fetch all the optimised image, calculate stats.
	 *
	 * @param bool $force_update Should force update or not.
	 *
	 * @return array Total stats.
	 */
	public function total_stats( $force_update = false ) {
		// If not forced to update.
		if ( ! $force_update ) {
			// Get stats from cache.
			$total_stats = wp_cache_get( 'wp-smush-dir_total_stats', 'wp-smush' );
			// If we have already calculated the stats and found in cache, return it.
			if ( false !== $total_stats ) {
				return $total_stats;
			}
		}

		global $wpdb;

		$offset    = 0;
		$optimised = 0;
		$limit     = 1000;
		$images    = array();
		$continue  = true;

		while ( $continue ) {
			$results = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT path, image_size, orig_size FROM {$wpdb->base_prefix}smush_dir_images WHERE image_size IS NOT NULL ORDER BY `id` LIMIT %d, %d",
					$offset,
					$limit
				),
				ARRAY_A
			); // Db call ok; no-cache ok.

			if ( ! $results ) {
				break;
			}

			$images  = array_merge( $images, $results );
			$offset += $limit;
		}

		// Iterate over stats, return count and savings.
		if ( ! empty( $images ) ) {
			// Init the stats array.
			$this->stats = array(
				'path'       => '',
				'image_size' => 0,
				'orig_size'  => 0,
			);

			foreach ( $images as $im ) {
				foreach ( $im as $key => $val ) {
					if ( 'path' === $key ) {
						$this->optimised_images[ $val ] = $im;
						continue;
					}
					$this->stats[ $key ] += (int) $val;
				}
				$optimised++;
			}
		}

		// Get the savings in bytes and percent.
		if ( ! empty( $this->stats ) && ! empty( $this->stats['orig_size'] ) ) {
			$this->stats['bytes']   = ( $this->stats['orig_size'] > $this->stats['image_size'] ) ? $this->stats['orig_size'] - $this->stats['image_size'] : 0;
			$this->stats['percent'] = number_format_i18n( ( ( $this->stats['bytes'] / $this->stats['orig_size'] ) * 100 ), 1 );
			// Convert to human-readable form.
			$this->stats['human'] = size_format( $this->stats['bytes'], 1 );
		}

		$this->stats['total']     = count( $images );
		$this->stats['optimised'] = $optimised;

		// Set stats in cache.
		wp_cache_set( 'wp-smush-dir_total_stats', $this->stats, 'wp-smush' );

		return $this->stats;
	}

	/**
	 * Returns the number of images scanned and optimised
	 *
	 * @return array
	 */
	private function last_scan_stats() {
		global $wpdb;
		$results = $wpdb->get_results( "SELECT id, image_size, orig_size FROM {$wpdb->base_prefix}smush_dir_images WHERE last_scan = (SELECT MAX(last_scan) FROM {$wpdb->base_prefix}smush_dir_images ) GROUP BY id", ARRAY_A ); // Db call ok; no-cache ok.
		$total   = count( $results );
		$smushed = 0;
		$stats   = array(
			'image_size' => 0,
			'orig_size'  => 0,
		);

		// Get the Smushed count, and stats sum.
		foreach ( $results as $image ) {
			if ( ! is_null( $image['image_size'] ) ) {
				$smushed++;
			}
			// Summation of stats.
			foreach ( $image as $k => $v ) {
				if ( 'id' === $k ) {
					continue;
				}
				$stats[ $k ] += $v;
			}
		}

		// Stats.
		$stats['total']   = $total;
		$stats['smushed'] = $smushed;

		return $stats;
	}

	/**
	 * Combine the stats from Directory Smush and Media Library Smush.
	 *
	 * @param array $stats  Directory Smush stats.
	 *
	 * @return array Combined array of stats.
	 */
	public function combine_stats( $stats ) {
		if ( empty( $stats ) || empty( $stats['percent'] ) || empty( $stats['bytes'] ) ) {
			return array();
		}

		$dasharray = 125.663706144;

		$core = WP_Smush::get_instance()->core();

		// Initialize global stats.
		$core->setup_global_stats();

		// Get the total/Smushed attachment count.
		$total_attachments = $core->total_count + $stats['total'];
		$total_images      = $core->stats['total_images'] + $stats['total'];

		$smushed     = $core->smushed_count + $stats['optimised'];
		$savings     = ! empty( $core->stats ) ? $core->stats['bytes'] + $stats['bytes'] : $stats['bytes'];
		$size_before = ! empty( $core->stats ) ? $core->stats['size_before'] + $stats['orig_size'] : $stats['orig_size'];
		$percent     = $size_before > 0 ? ( $savings / $size_before ) * 100 : 0;

		// Store the stats in array.
		return array(
			'total_count'   => $total_attachments,
			'smushed_count' => $smushed,
			'savings'       => size_format( $savings ),
			'percent'       => round( $percent, 1 ),
			'image_count'   => $total_images,
			'dash_offset'   => $total_attachments > 0 ? $dasharray - ( $dasharray * ( $smushed / $total_attachments ) ) : $dasharray,
			/* translators: %s: total number of images */
			'tooltip_text'  => ! empty( $total_images ) ? sprintf( __( "You've smushed %d images in total.", 'wp-smushit' ), $total_images ) : '',
		);
	}

	/**
	 * Check and create dir smush table if required.
	 *
	 * @since 2.9.0
	 */
	public function check_table() {
		// Get current screen.
		$current_screen = get_current_screen();

		// Only run on required pages.
		if ( ! empty( $current_screen ) && false === strpos( $current_screen->id, 'page_smush' ) ) {
			return;
		}

		// Create custom table for directory smush.
		if ( ! self::table_exist() ) {
			Installer::directory_smush_table();
		}
	}

	/**
	 * Check if required directory smush table exist.
	 *
	 * @param bool $force Should force check?.
	 *
	 * @since 2.9.0
	 *
	 * @return bool
	 */
	public static function table_exist( $force = false ) {
		global $wpdb;

		// If not forced, try to get from cache.
		if ( ! $force && isset( self::$table_exist ) ) {
			return self::$table_exist;
		}

		// If not already checked, check.
		$table_exist = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $wpdb->base_prefix . 'smush_dir_images' ) ) ); // Db call ok; no-cache ok.

		self::$table_exist = (bool) $table_exist;

		return self::$table_exist;
	}

	/**
	 * Remove directory smush from tabs.
	 *
	 * If not in main site, do not show directory smush.
	 *
	 * @param array $tabs Tabs.
	 *
	 * @return array
	 */
	public function remove_directory_tab( $tabs ) {
		if ( isset( $tabs['directory'] ) ) {
			unset( $tabs['directory'] );
		}

		return $tabs;
	}

	/**
	 * Display a admin notice on smush screen if the custom table wasn't created
	 */
	public function show_table_error() {
		if ( ! self::table_exist() ) { // Display a notice.
			?>
		<div class="sui-notice sui-notice-warning">
			<div class="sui-notice-content">
				<div class="sui-notice-message">
					<i class="sui-notice-icon sui-icon-info" aria-hidden="true"></i>
					<p>
						<?php esc_html_e( 'Directory smushing requires custom tables and it seems there was an error creating tables. For help, please contact our team on the support forums.', 'wp-smushit' ); ?>
					</p>
				</div>
			</div>
		</div>
			<?php
		}
	}

	/**
	 * Localize directory smush settings for React.
	 *
	 * @param array $localize Current localize data.
	 *
	 * @return array
	 */
	public function localize_dir_script_data( $localize ) {
		$dir_settings = array(
			'dir_lossy'      => Settings::get_instance()->get_dir_lossy_level_setting(),
			'dir_strip_exif' => Settings::get_instance()->get_dir_strip_exif_setting(),
		);

		$localize['directorySettings']                = Dir_Settings_DTO::to_react_props( $dir_settings );
		$localize['directorySettings']['tableExists'] = self::table_exist();

		return $localize;
	}

	/**
	 * Handle directory smush settings sync via unified endpoint.
	 *
	 * @param array|null $saved_settings Saved settings from previous filter, or null.
	 * @param array      $settings       Incoming settings from React (camelCase).
	 * @param string     $context        Context identifier.
	 *
	 * @return array|null Saved settings array if context matches, otherwise pass through.
	 */
	public function handle_settings_sync( $saved_settings, $settings, $context ) {
		if ( 'directory' !== $context ) {
			return $saved_settings;
		}

		$db_settings = Dir_Settings_DTO::from_react_props( $settings );

		// Save directory settings to separate option
		$dir_settings = array(
			'dir_lossy'      => $this->settings->get_dir_lossy_level_setting(),
			'dir_strip_exif' => $this->settings->get_dir_strip_exif_setting(),
		);
		foreach ( $db_settings as $key => $value ) {
			if ( in_array( $key, array( 'dir_lossy', 'dir_strip_exif' ), true ) ) {
				$dir_settings[ $key ] = $value;
			}
		}
		if ( ! empty( $dir_settings ) ) {
			$this->settings->update_dir_settings( $dir_settings );
		}

		$updated_settings = array(
			'dir_lossy'      => $this->settings->get_dir_lossy_level_setting(),
			'dir_strip_exif' => $this->settings->get_dir_strip_exif_setting(),
		);

		return Dir_Settings_DTO::to_react_props( $updated_settings );
	}
}
