<?php
if (!defined('ABSPATH')) {
	exit;//Exit if accessed directly
}

class AIOWPSecurity_Scan {

	public function __construct() {
		add_action('aiowps_perform_fcd_scan_tasks', array($this, 'aiowps_scheduled_fcd_scan_handler'));
	}
	
	/**
	 * This function will recursively scan through all directories starting from the specified location
	 * It will store the path/filename, last_modified and filesize values in a multi-dimensional associative array
	 */
	
	/**
	 * Will recursively scan through all directories starting from ABSPATH.
	 * Will return array with the path/filename, last_modified and filesize values
	 *
	 * @global AIO_WP_Security $aio_wp_security
	 * @return boolean|array
	 */
	public function execute_file_change_detection_scan() {
		global $aio_wp_security;
		$scan_result = array();
		$fcd_filename = $aio_wp_security->configs->get_value('aiowps_fcd_filename');
		if (empty($fcd_filename)) {
			// means that we haven't done a scan before, or,
			// the fcd file containing the results doesn't exist
			$random_suffix = AIOWPSecurity_Utility::generate_alpha_numeric_random_string(10);
			$fcd_filename = 'aiowps_fcd_data_' . $random_suffix;
			$aio_wp_security->configs->set_value('aiowps_fcd_filename', $fcd_filename, true);
		}

		$fcd_data = self::get_fcd_data(); // get previous scan data if any

		if (false === $fcd_data) {
			// an error occurred so return
			return false;
		}
		
		$scanned_data = $this->do_file_change_scan();
		
		if (empty($fcd_data)) {
			$this->save_fcd_data($scanned_data);
			$scan_result['initial_scan'] = '1';
			return $scan_result;
		} else {
		
			$scan_result = $this->compare_scan_data($fcd_data['file_scan_data'], $scanned_data);
			
			$scan_result['initial_scan'] = '';
			$this->save_fcd_data($scanned_data, $scan_result);
			if (!empty($scan_result['files_added']) || !empty($scan_result['files_removed']) || !empty($scan_result['files_changed'])) {
				//This means there was a change detected
				$aio_wp_security->configs->set_value('aiowps_fcds_change_detected', true, true);
				$aio_wp_security->debug_logger->log_debug(__METHOD__ . " - change to filesystem detected!");

				$this->aiowps_send_file_change_alert_email($scan_result); //Send file change scan results via email if applicable
			} else {
				//Reset the change flag
				$aio_wp_security->configs->set_value('aiowps_fcds_change_detected', false, true);
			}
			return $scan_result;
		}
	}

	/**
	 * Send email with notification about file changes detected by last scan.
	 *
	 * @global AIO_WP_Security $aio_wp_security
	 * @param array $scan_result Array with scan result returned by compare_scan_data() method.
	 */
	public function aiowps_send_file_change_alert_email($scan_result) {
		global $aio_wp_security;
		if ('1' == $aio_wp_security->configs->get_value('aiowps_send_fcd_scan_email')) {
			$subject = __('All In One Security - File change detected', 'all-in-one-wp-security-and-firewall') . ' ' . AIOWPSecurity_Utility::convert_timestamp(null, 'l, F jS, Y \a\\t g:i a');
			//$attachment = array();
			$message = __('A file change was detected on your system for site URL', 'all-in-one-wp-security-and-firewall') . ' ' . network_site_url() . __('. Scan was generated on', 'all-in-one-wp-security-and-firewall') . ' ' . AIOWPSecurity_Utility::convert_timestamp(null, 'l, F jS, Y \a\\t g:i a');
			$message .= "\r\n\r\n".__('A summary of the scan results is shown below:', 'all-in-one-wp-security-and-firewall');
			$message .= "\r\n\r\n";
			$message .= self::get_file_change_summary($scan_result);
			$message .= "\r\n".__('Login to your site to view the scan details.', 'all-in-one-wp-security-and-firewall');

			// Get the email address(es).
			$addresses = AIOWPSecurity_Utility::get_array_from_textarea_val($aio_wp_security->configs->get_value('aiowps_fcd_scan_email_address'));
			// If no explicit email address(es) are given, send email to site admin.
			$to = empty($addresses) ? array(get_site_option('admin_email')) : $addresses;
			if (!wp_mail($to, $subject, $message)) {
				$aio_wp_security->debug_logger->log_debug(__METHOD__ . " - File change notification email failed to send.", 4);
			}

		}
	}
	
	/**
	 * This function is called via the following filter 'aiowps_perform_fcd_scan_tasks' and will start the file scan
	 *
	 * @return void
	 */
	public function aiowps_scheduled_fcd_scan_handler() {
		global $aio_wp_security;
		
		if ('1' != $aio_wp_security->configs->get_value('aiowps_enable_automated_fcd_scan')) return;
		
		$aio_wp_security->debug_logger->log_debug_cron(__METHOD__ . " - Scheduled fcd_scan is enabled. Checking now to see if scan needs to be done...");
		
		$current_time = time();
		
		$next_fcd_scan_time = self::get_next_scheduled_scan();

		if ($next_fcd_scan_time <= $current_time) {
			// It's time to do a filescan
			$result = $this->execute_file_change_detection_scan();
			if (false === $result) {
				$aio_wp_security->debug_logger->log_debug(__METHOD__ . " - Scheduled filescan operation failed.", 4);
			} else {
				$aio_wp_security->configs->set_value('aiowps_last_fcd_scan_time', $current_time, true);
			}
		}

	}

	/**
	 * This function will get the next scheduled scan timestamp and return it
	 *
	 * @return int|bool - the next scheduled scan timestamp, or false if the scheduled scan is not setup
	 */
	public static function get_next_scheduled_scan() {
		global $aio_wp_security;

		if ('1' != $aio_wp_security->configs->get_value('aiowps_enable_automated_fcd_scan')) return false;

		$fcd_scan_frequency = $aio_wp_security->configs->get_value('aiowps_fcd_scan_frequency'); // Number of hours or days or months interval
		$interval_setting = $aio_wp_security->configs->get_value('aiowps_fcd_scan_interval'); // Hours/Days/Months
		switch ($interval_setting) {
			case '0':
				$interval = 'hours';
				break;
			case '1':
				$interval = 'days';
				break;
			case '2':
				$interval = 'weeks';
				break;
		}
		$last_fcd_scan_time = $aio_wp_security->configs->get_value('aiowps_last_fcd_scan_time');
		if (null == $last_fcd_scan_time) {
			// Set the last scan time to now so it can trigger for the next scheduled period
			$last_fcd_scan_time = time();
			$aio_wp_security->configs->set_value('aiowps_last_fcd_scan_time', $last_fcd_scan_time, true);
		} elseif (is_string($last_fcd_scan_time)) {
			$last_fcd_scan_time = strtotime($last_fcd_scan_time);
			$aio_wp_security->configs->set_value('aiowps_last_fcd_scan_time', $last_fcd_scan_time, true);
		}
		$next_fcd_scan_time = strtotime("+".abs($fcd_scan_frequency).$interval, $last_fcd_scan_time);

		return $next_fcd_scan_time;
	}
	
	/**
	 * Get the last filechange detection data which is stored in the special file.
	 *
	 * @global AIO_WP_Security $aio_wp_security
	 * @return bool|array - false on failure, array on success
	 */
	public static function get_fcd_data() {
		// phpcs:disable WordPress.WP.AlternativeFunctions -- Silence wp_filesystem method suggestion. We cannot use that method.
		global $aio_wp_security;
		$aiowps_backup_dir = WP_CONTENT_DIR.'/'.AIO_WP_SECURITY_BACKUPS_DIR_NAME;
		
		$fcd_filename = $aio_wp_security->configs->get_value('aiowps_fcd_filename');
		if (empty($fcd_filename)) {
			// means that we haven't done a scan before, or,
			// the fcd file containing the results doesn't exist
			$random_suffix = AIOWPSecurity_Utility::generate_alpha_numeric_random_string(10);
			$fcd_filename = 'aiowps_fcd_data_' . $random_suffix;
			$aio_wp_security->configs->set_value('aiowps_fcd_filename', $fcd_filename, true);
		}
		$results_file = $aiowps_backup_dir. '/'. $fcd_filename;
		
		if (!is_file($results_file)) {
			if (is_dir($results_file)) {
				$new_dir_name = $results_file . '_backup';
				rename($results_file, $new_dir_name); //Rename the folder to create backup of the folder. This condition should not really happen, but if it does (user sets some non-sensible value), then it's better to not nuke the existing folder
			}
			$fp = @fopen($results_file, 'w'); //open for write - will create file if doesn't exist
			return array();
		}
		
		if (empty(filesize($results_file))) {
			return array(); // if newly created file return empty array
		}
		
		$fp = @fopen($results_file, 'r'); //open for read and write - will create file if doesn't exist
		if (false === $fp) {
			// Error
			$aio_wp_security->debug_logger->log_debug(__METHOD__ . " - fopen returned false when opening fcd data file");
			return false;
		}
		
		$contents = fread($fp, filesize($results_file));
		fclose($fp);
		if (false === $contents) {
			// Error
			$aio_wp_security->debug_logger->log_debug(__METHOD__ . " - fread returned false when reading fcd data file");
			return false;
		} else {
			
			$fcd_file_contents = json_decode($contents, true);
			if (isset($fcd_file_contents['file_scan_data'])) {
				return $fcd_file_contents;
			} else {
				return array();
			}
			
		}

		// phpcs:enable WordPress.WP.AlternativeFunctions -- Silence wp_filesystem method suggestion. We cannot use that method.
	}
	
	/**
	 * Recursively scan the entire $start_dir directory and return file size
	 * and last modified date of every regular file. Ignore files and file
	 * types specified in file scanner settings.
	 *
	 * @global AIO_WP_Security $aio_wp_security
	 * @param string $start_dir
	 * @return array
	 */
	public function do_file_change_scan($start_dir = ABSPATH) {
		global $aio_wp_security;
		$filescan_data = array();
		// Iterator key is absolute file path, iterator value is SplFileInfo object,
		// iteration skips '..' and '.' records, because we're not interested in directories.
		$dit = new RecursiveDirectoryIterator($start_dir, FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS);
		$rit = new RecursiveIteratorIterator($dit, RecursiveIteratorIterator::SELF_FIRST, RecursiveIteratorIterator::CATCH_GET_CHILD);

		// Grab files/directories to skip
		$files_to_skip = AIOWPSecurity_Utility::explode_trim_filter_empty($aio_wp_security->configs->get_value('aiowps_fcd_exclude_files'));
		// Grab (lowercased) file types to skip
		$file_types_to_skip = AIOWPSecurity_Utility::explode_trim_filter_empty(strtolower($aio_wp_security->configs->get_value('aiowps_fcd_exclude_filetypes')));

		$start_dir_length = strlen($start_dir);

		foreach ($rit as $filename => $fileinfo) {

			if (!file_exists($filename) || is_dir($filename)) {
				continue; // if file doesn't exist or is a directory move on to next iteration
			}

			if ($fileinfo->getFilename() == 'wp-security-log-cron-job.txt' || $fileinfo->getFilename() == 'wp-security-log.txt') {
				continue; // Skip AIOS log files
			}

			// Let's omit any file types from the scan which were specified in the settings if necessary
			if (!empty($file_types_to_skip)) {
				//$current_file_ext = strtolower($fileinfo->getExtension()); //getExtension() only available on PHP 5.3.6 or higher
				$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
				if (in_array($ext, $file_types_to_skip)) {
					continue;
				}
			}

			// Let's omit specific files or directories from the scan which were specified in the settings
			if (!empty($files_to_skip)) {

				$skip_this = false;
				foreach ($files_to_skip as $f_or_dir) {
					// Expect files/dirs to be specified relatively to $start_dir,
					// so start searching at $start_dir_length offset.
					if (strpos($filename, $f_or_dir, $start_dir_length) !== false) {
						$skip_this = true;
						break; // !
					}
				}
				if ($skip_this) {
					continue;
				}
			}

			$filescan_data[$filename] = array(
				'last_modified' => $fileinfo->getMTime(),
				'filesize'      => $fileinfo->getSize(),
			);

		}
		return $filescan_data;
	}
	
	public function compare_scan_data($last_scan_data, $new_scanned_data) {
		// Identify new files added: get all files which are in the new scan but not present in the old scan
		$files_added = @array_diff_key($new_scanned_data, $last_scan_data);
		// Identify files deleted: get all files which are in the old scan but not present in the new scan
		$files_removed = @array_diff_key($last_scan_data, $new_scanned_data);
		// Identify existing files: get all files which are in new scan, but were not added
		$files_kept = @array_diff_key($new_scanned_data, $files_added);

		$files_changed = array();

		// Loop through existing files and determine, if they have been changed
		foreach ($files_kept as $filename => $new_scan_meta) {
			$last_scan_meta = $last_scan_data[$filename];
			// Check filesize and last_modified values
			if (($new_scan_meta['last_modified'] !== $last_scan_meta['last_modified']) || ($new_scan_meta['filesize'] !== $last_scan_meta['filesize'])) {
				$files_changed[$filename] = $new_scan_meta;
			}
		}

		// Create single array of all changes
		return array(
			'files_added' => $files_added,
			'files_removed' => $files_removed,
			'files_changed' => $files_changed,
		);
	}

	public static function get_file_change_summary($scan_result) {
		$scan_summary = "";
		if (!empty($scan_result['files_added'])) {
			//Output of files added
			$scan_summary .= "\r\n".__('The following files were added to your host', 'all-in-one-wp-security-and-firewall').":\r\n";
			foreach ($scan_result['files_added'] as $key => $value) {
				$scan_summary .= "\r\n".$key.' ('.__('modified on:', 'all-in-one-wp-security-and-firewall'). ' ' . AIOWPSecurity_Utility::convert_timestamp($value['last_modified']).')';
			}
			$scan_summary .= "\r\n======================================\r\n";
		}
		if (!empty($scan_result['files_removed'])) {
			//Output of files removed
			$scan_summary .= "\r\n".__('The following files were removed from your host', 'all-in-one-wp-security-and-firewall').":\r\n";
			foreach ($scan_result['files_removed'] as $key => $value) {
				$scan_summary .= "\r\n".$key.' ('.__('modified on:', 'all-in-one-wp-security-and-firewall'). ' ' . AIOWPSecurity_Utility::convert_timestamp($value['last_modified']).')';
			}
			$scan_summary .= "\r\n======================================\r\n";
		}

		if (!empty($scan_result['files_changed'])) {
			//Output of files changed
			$scan_summary .= "\r\n".__('The following files were changed on your host', 'all-in-one-wp-security-and-firewall').":\r\n";
			foreach ($scan_result['files_changed'] as $key => $value) {
				$scan_summary .= "\r\n".$key.' ('.__('modified on:', 'all-in-one-wp-security-and-firewall'). ' ' . AIOWPSecurity_Utility::convert_timestamp($value['last_modified']).')';
			}
			$scan_summary .= "\r\n======================================\r\n";
		}

		return $scan_summary;
	}

	/**
	 * Saves file change detection data into a special file
	 *
	 * @global AIO_WP_Security $aio_wp_security
	 * @param type $scanned_data
	 * @param type $scan_result
	 * @return boolean
	 */
	public function save_fcd_data($scanned_data, $scan_result = array()) {
		global $aio_wp_security;

		$date_time = current_time('mysql');
		$data = array('date_time' => $date_time, 'file_scan_data' => $scanned_data, 'last_scan_result' => $scan_result);
		
		$fcd_filename = $aio_wp_security->configs->get_value('aiowps_fcd_filename');
		$aiowps_backup_dir = WP_CONTENT_DIR.'/'.AIO_WP_SECURITY_BACKUPS_DIR_NAME;
		
		if (!AIOWPSecurity_Utility_File::create_dir($aiowps_backup_dir)) {
			$aio_wp_security->debug_logger->log_debug(__METHOD__ . " - Creation of DB backup directory failed!", 4);
			return false;
		}

		// phpcs:disable WordPress.WP.AlternativeFunctions -- Silence wp_filesystem method suggestion. We cannot use that method.
		$results_file = $aiowps_backup_dir. '/'. $fcd_filename;
		$fp = fopen($results_file, 'w');
		fwrite($fp, json_encode($data));
		fclose($fp);
		// phpcs:enable WordPress.WP.AlternativeFunctions -- Silence wp_filesystem method suggestion. We cannot use that method.

		return true;
	}
}
