<?php
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Plugin uses custom tables and controlled internal query builders.
namespace SPAATG\Replacer;

if (! defined('ABSPATH')) {
	exit; // Exit if accessed directly.
}

use Helper\Functional;
use SPAATG\Replacer\Classes\Finder;
use SPAATG\Replacer\Classes\Setup;
use SPAATG\Replacer\Classes\Updater;
use SPAATG\ShortPixelLogger\ShortPixelLogger as Log;
use SPAATG\Replacer\Libraries\Unserialize\Unserialize;

/** Module: Replacer.
 *
 * - Able to replace across database
 * - Only replace thumbnails feature dependent on media library
 * - Support for page builders / strange data
 */

class Replacer
{

	protected $source_url;
	protected $target_url;
	protected $source_metadata = [];
	protected $target_metadata = [];

	protected static $instance; 

	private $default_replace_settings = array(
		'component' => 'unset',
		'json_flags' => JSON_UNESCAPED_SLASHES,
		'replacer_do_save' => true,
		'replace_no_serialize' => false,
	);

	private $replace_settings;

	public function __construct()
	{
		$this->loadFormats();
	}

	public static function getInstance()
	{
		 if (is_null(self::$instance))
		 {
			 self::$instance = new Replacer(); 
		 }

		 return self::$instance; 
	}

	// Load classes that handle alternative formats that can occur in the metadata / post data.
	protected function loadFormats()
	{
		Modules\Elementor::getInstance();
		Modules\WpBakery::getInstance();
		Modules\YoastSeo::getInstance();
		Modules\Breakdance::getInstance();
		Modules\SmartSlider::getInstance();
	}

	public function setSource($url)
	{
		$this->source_url = $url;
	}

	public function getSource()
	{
		return $this->source_url;
	}

	public function setTarget($url)
	{
		$this->target_url = $url;
	}

	public function getTarget()
	{
		return $this->target_url;
	}

	public function setSourceMeta($meta)
	{
		$this->source_metadata = $meta;
	}

	public function setTargetMeta($meta)
	{
		$this->target_metadata = $meta;
	}

	public function Setup()
	{
		 return Setup::getInstance();
	}
	
	public function Finder($args = [])
	{
		 
		 return new Finder($args);
	}

	public function Updater()
	{
		 return new Updater();
	}

	public function replace($args = array())
	{
		if (is_null($this->source_url) || is_null($this->target_url)) {
			Log::addWarn('Replacer called without source or target ');
			return false;
		}
		$defaults = array(
			'thumbnails_only' => false,
		);

		$errors = array();
		$args = wp_parse_args($args, $defaults);

		$this->setReplaceSettings(['component' => 'emr']); // set to defaults.

		// Search-and-replace filename in post database
		// @todo Check this with scaled images.
		$base_url = wp_parse_url($this->source_url, PHP_URL_PATH);
		$base_url = str_replace('.' . pathinfo($base_url, PATHINFO_EXTENSION), '', $base_url);

		/** Fail-safe if base_url is a whole directory, don't go search/replace */
		if (false === $this->fileIsRestricted($base_url) && is_dir($base_url)) {
			Log::addError('Search Replace tried to replace to directory - ' . $base_url);
			$errors[] = __('Fail Safe :: Source Location seems to be a directory.', 'shortpixel-ai-alt-text-generator');
			return $errors;
		}

		if (strlen(trim($base_url)) == 0) {
			Log::addError('Current Base URL emtpy - ' . $base_url);
			$errors[] = __('Fail Safe :: Source Location returned empty string. Not replacing content', 'shortpixel-ai-alt-text-generator');
			return $errors;
		}

		// get relurls of both source and target.
		$urls = $this->getRelativeURLS();


		if ($args['thumbnails_only']) {
			foreach ($urls as $side => $data) {
				if (isset($data['base'])) {
					unset($urls[$side]['base']);
				}
				if (isset($data['file'])) {
					unset($urls[$side]['file']);
				}
			}
		}

		$search_urls = $urls['source'];
		$replace_urls = $urls['target'];

		/* If the replacement is much larger than the source, there can be more thumbnails. This leads to disbalance in the search/replace arrays.
	      Remove those from the equation. If the size doesn't exist in the source, it shouldn't be in use either */
		foreach ($replace_urls as $size => $url) {
			if (! isset($search_urls[$size])) {
				Log::addDebug('Dropping size ' . $size . ' - not found in source urls');
				unset($replace_urls[$size]);
			}
		}

		Log::addDebug('Source', $this->source_metadata);
		Log::addDebug('Target', $this->target_metadata);
		/* If on the other hand, some sizes are available in source, but not in target, try to replace them with something closeby.  */
		foreach ($search_urls as $size => $url) {
			if (! isset($replace_urls[$size])) {
				$closest = $this->findNearestSize($size);
				if ($closest) {
					$sourceUrl = $search_urls[$size];
					$baseurl = trailingslashit(str_replace(wp_basename($sourceUrl), '', $sourceUrl));
					Log::addDebug('Nearest size of source ' . $size . ' for target is ' . $closest);
					$replace_urls[$size] = $baseurl . $closest;
				} else {
					Log::addDebug('Unset size ' . $size . ' - no closest found in source');
				}
			}
		}

		/* If source and target are the same, remove them from replace. This happens when replacing a file with same name, and +/- same dimensions generated.

	    After previous loops, for every search there should be a replace size.
	    */
		foreach ($search_urls as $size => $url) {
			$replace_url = isset($replace_urls[$size]) ? $replace_urls[$size] : false;
			if ($url == $replace_url) // if source and target as the same, no need for replacing.
			{
				unset($search_urls[$size]);
				unset($replace_urls[$size]);
			}
		}

		// If the two sides are disbalanced, the str_replace part will cause everything that has an empty replace counterpart to replace it with empty. Unwanted.
		if (count($search_urls) !== count($replace_urls)) {
			Log::addError('Unbalanced Replace Arrays, aborting', array($search_urls, $replace_urls, count($search_urls), count($replace_urls)));
			$errors[] = __('There was an issue with updating your image URLS: Search and replace have different amount of values. Aborting updating thumbnails', 'shortpixel-ai-alt-text-generator');
			return $errors;
		}

		Log::addDebug('Doing meta search and replace -', array($search_urls, $replace_urls));
		Log::addDebug('Searching with BaseuRL ' . $base_url);

		do_action('spaatg/replacer/replace_urls', $search_urls, $replace_urls);
		$updated = 0;

		$updated += $this->doReplaceQuery($base_url, $search_urls, $replace_urls);

		$replaceRuns = apply_filters('spaatg/replacer/custom_replace_query', array(), $base_url, $search_urls, $replace_urls);

		foreach ($replaceRuns as $component => $run) {
			Log::addDebug('Running additional replace for : ' . $component, $run);

			// @todo This could perhaps benefit from a more general approach somewhere in class for settings.
			if (isset($run['args'])) {
				// Update current settings for this run only.
				$this->setReplaceSettings($run['args']);
			} else {
				$this->setReplaceSettings();
			}
			$updated += $this->doReplaceQuery($run['base_url'], $run['search_urls'], $run['replace_urls']);
		}

		Log::addDebug("Updated Records : " . $updated);
		return $updated;
	}

	private function doReplaceQuery($base_url, $search_urls, $replace_urls)
	{
		global $wpdb;
		/* Search and replace in WP_POSTS */
		// Removed $wpdb->remove_placeholder_escape from here, not compatible with WP 4.8

		$posts_sql = $wpdb->prepare(
			"SELECT ID, post_content FROM $wpdb->posts WHERE post_status in ('publish', 'future', 'draft', 'pending', 'private')
				AND post_content LIKE %s",
			'%' . $base_url . '%'
		);

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above; table name comes from $wpdb.
			$rs = $wpdb->get_results($posts_sql, ARRAY_A);
		$number_of_updates = 0;

		if (! empty($rs)) {
			foreach ($rs as $rows) {
				$number_of_updates = $number_of_updates + 1;
				// replace old URLs with new URLs.

				$post_content = $rows["post_content"];
				$post_id = $rows['ID'];
				$replaced_content = $this->replaceContent($post_content, $search_urls, $replace_urls, false, true);

				if ($replaced_content !== $post_content) {

					//  $result = wp_update_post($post_ar);
					$sql = 'UPDATE ' . $wpdb->posts . ' SET post_content = %s WHERE ID = %d';
					// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query string contains validated dynamic table or clause fragments and is prepared before execution.
					$sql = $wpdb->prepare($sql, $replaced_content, $post_id);

					// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above; table name comes from $wpdb.
					$result = $wpdb->query($sql);

					if ($result === false) {
						// Notice::addError('Something went wrong while replacing' .  $result->get_error_message() );
						Log::addError('WP-Error during post update', $result);
					}
				}
			}
		}

		$number_of_updates += $this->handleMetaData($base_url, $search_urls, $replace_urls);
		return $number_of_updates;
	}

	private function handleMetaData($url, $search_urls, $replace_urls)
	{
		global $wpdb;

		$meta_options = apply_filters('spaatg/replacer/metadata_tables', array('post', 'comment', 'term', 'user', 'options'));
		$number_of_updates = 0;

		$meta_default = [
			'id' => 'meta_id',
			'value' => 'meta_value',
		];

		$table_options = [
			'postmeta' => $meta_default,
			'commentmeta' => $meta_default,
			'termmeta' => $meta_default,
			'usermeta' => $meta_default,
			'options' => [
				'id' => 'option_id',
				'value' => 'option_value',
			]

		];

		$table_options = apply_filters('spaatg/replacer/replacement_tables', $table_options);

		// Exeception in user meta table.
		$table_options['usermeta']['id'] = 'umeta_id';

		foreach ($table_options as $table => $data) {
			// These must be always defined.
			$id_field = esc_sql($data['id']);
			$value_field = esc_sql($data['value']);

			switch ($table) {
				case "postmeta": // special case.
					$sql = 'SELECT * FROM ' . $wpdb->postmeta . '
	                WHERE post_id in (SELECT ID from ' . $wpdb->posts . ' where post_status in ("publish", "future", "draft", "pending", "private") ) AND meta_value like %s';
					$type = 'post';

					$update_sql = ' UPDATE ' . $wpdb->postmeta . ' SET meta_value = %s WHERE meta_id = %d';
					break;
				default:
					$wp_table = $wpdb->{$table};  // termmeta, commentmeta etc

					$sql = "SELECT $id_field , $value_field FROM $wp_table WHERE $value_field like %s";

					$update_sql = " UPDATE $wp_table set $value_field = %s WHERE $id_field  = %d ";
					break;
			}

			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query string contains validated dynamic table or clause fragments and is prepared before execution.
			$sql = $wpdb->prepare($sql, '%' . $url . '%');

			// This is a desparate solution. Can't find anyway for wpdb->prepare not the add extra slashes to the query, which messes up the query.
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above; table/field identifiers are plugin-selected.
			$rsmeta = $wpdb->get_results($sql, ARRAY_A);

			if (! empty($rsmeta)) {
				foreach ($rsmeta as $row) {
					$number_of_updates++;
					$content = $row[$value_field];

					$component = $this->replace_settings['component'];

					$id = $row[$id_field];

					// Content as how it's loading.
					$content = apply_filters('spaatg/replacer/load_meta_value', $content, $row, $component);

					// If content is null, break out of everything and don't replace this.
					if (null === $content) {
						Log::addDebug('Content returned null, aborting this record, meta_id : ' . $id_field);
					} else {
						$content = $this->replaceContent($content, $search_urls, $replace_urls);

						// Content as how it's going to dbase.
					$content = apply_filters('spaatg/replacer/save_meta_value', $content, $row, $component);

						// Check if usual save should be prevented. This is for integrations.
						if (true === $this->replace_settings['replacer_do_save']) {
							// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query string contains validated dynamic table or clause fragments and is prepared before execution.
							$prepared_sql = $wpdb->prepare($update_sql, $content, $id);
							// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared above; table/field identifiers are plugin-selected.
							$result = $wpdb->query($prepared_sql);
						}
					}
				} // Loop
			} // if
		} // foreach

		return $number_of_updates;
	} // function


	private function setReplaceSettings($settings = array())
	{
		$this->replace_settings = wp_parse_args($settings, $this->default_replace_settings);
	}

	/**
	 * Replaces Content across several levels of possible data
	 * @param $content String The Content to replace
	 * @param $search Array Search string
	 * @param $replace Array Replacement String
	 * @param $in_deep Boolean.  This is use to prevent serialization of sublevels. Only pass back serialized from top.
	 * @param $strict_check Boolean . If true, remove all classes from serialization check and fail. This should be done on post_content, not on metadata.
	 */
	public function replaceContent($content, $search, $replace, $in_deep = false, $strict_check = false)
	{

		// Since ReplaceContent can now be called directly, this might not be set, set defaults if so
		if (is_null($this->replace_settings)) {
			$this->setReplaceSettings([]);
		}

		if (true === is_serialized($content)) {
			$serialized_content = $content; // use to return content back if incomplete classes are found, prevent destroying the original information

			if (true === $strict_check) {
				$args = array('allowed_classes' => false);
			} else {
				$args = array('allowed_classes' => true);
			}

			$content = Unserialize::unserialize($content, $args);
			// bail directly on incomplete classes. In < PHP 7.2 is_object is false on incomplete objects!
			if (true === $this->checkIncomplete($content)) {
				return $serialized_content;
			}
		}

		$isJson = $this->isJSON($content);

		if ($isJson) {
			$content = json_decode($content);
		}

		if (is_string($content))  // let's check the normal one first.
		{
			$content = apply_filters('spaatg/replacer/content', $content, $search, $replace);
			$content = str_replace($search, $replace, $content);
		} elseif (is_wp_error($content)) // seen this.
		{
			//return $content;  // do nothing.
		} elseif (is_array($content)) // array metadata and such.
		{
			foreach ($content as $index => $value) {
				$content[$index] = $this->replaceContent($value, $search, $replace, true); //str_replace($value, $search, $replace);
				if (is_string($index)) // If the key is the URL (sigh)
				{

					$index_replaced = $this->replaceContent($index, $search, $replace, true);
					if ($index_replaced !== $index)
						$content = $this->change_key($content, array($index => $index_replaced));
				}
			}
		} elseif (is_object($content)) // metadata objects, they exist.
		{
			// bail directly on incomplete classes.
			if (true === $this->checkIncomplete($content)) {
				// if it was serialized, return the original as not to corrupt data.
				if (isset($serialized_content)) {
					return $serialized_content;
				} else { // else just return the content.
					return $content;
				}
			}
			foreach ($content as $key => $value) {
				$content->{$key} = $this->replaceContent($value, $search, $replace, true);
			}
		}

		if ($isJson && $in_deep === false) // convert back to JSON, if this was JSON. Different than serialize which does WP automatically.
		{

			// wp-slash -> WP does stripslashes_deep which destroys JSON
			$json_flags = $this->replace_settings['json_flags'];
			Log::addDebug('Value was found to be JSON, encoding with flag: ' . $json_flags);
			$content = json_encode($content, $json_flags);
			Log::addDebug('Content returning (array ours) ', array($content));
		} elseif (
			$this->replace_settings['replace_no_serialize'] === false &&
			$in_deep === false && (is_array($content) || is_object($content))
		) {
			$content = maybe_serialize($content);
		}
		return $content;
	}

	/** Check if path is allowed within openbasedir restrictions. This is an attempt to limit notices in file funtions if so.  Most likely the path will be relative in that case.
	 * @param String Path as String
	 */
	private function fileIsRestricted($path)
	{

		$basedir = ini_get('open_basedir');

		if (false === $basedir || strlen($basedir) == 0) {
			return false;
		}

		$restricted = true;
		$basedirs = preg_split('/:|;/i', $basedir);

		foreach ($basedirs as $basepath) {
			if (strpos($path, $basepath) !== false) {
				$restricted = false;
				break;
			}
		}

		// Allow this to be overridden due to specific server configs ( ie symlinks ) might get this flagged falsely.
		$restricted = apply_filters('spaatg/file/basedir_check', $restricted);

		return $restricted;
	}

	private function change_key($arr, $set)
	{
		if (is_array($arr) && is_array($set)) {
			$newArr = array();
			foreach ($arr as $k => $v) {
				$key = array_key_exists($k, $set) ? $set[$k] : $k;
				$newArr[$key] = is_array($v) ? $this->change_key($v, $set) : $v;
			}
			return $newArr;
		}
		return $arr;
	}

	private function getRelativeURLS()
	{
		$dataArray = array(
			'source' => array('url' => $this->source_url, 'metadata' => $this->getFilesFromMetadata($this->source_metadata)),
			'target' => array('url' => $this->target_url, 'metadata' => $this->getFilesFromMetadata($this->target_metadata)),
		);

		//  Log::addDebug('Source Metadata', $this->source_metadata);
		//    Log::addDebug('Target Metadata', $this->target_metadata);

		$result = array();

		foreach ($dataArray as $index => $item) {
			$result[$index] = array();
			$metadata = $item['metadata'];

			$baseurl = wp_parse_url($item['url'], PHP_URL_PATH);
			$result[$index]['base'] = $baseurl;  // this is the relpath of the mainfile.
			$baseurl = trailingslashit(str_replace(wp_basename($item['url']), '', $baseurl)); // get the relpath of main file.

			foreach ($metadata as $name => $filename) {
				$result[$index][$name] =  $baseurl . wp_basename($filename); // filename can have a path like 19/08 etc.
			}
		}
		//    Log::addDebug('Relative URLS', $result);
		return $result;
	}


	private function getFilesFromMetadata($meta)
	{
		$fileArray = array();
		if (isset($meta['file']))
			$fileArray['file'] = $meta['file'];

		if (isset($meta['sizes'])) {
			foreach ($meta['sizes'] as $name => $data) {
				if (isset($data['file'])) {
					$fileArray[$name] = $data['file'];
				}
			}
		}
		return $fileArray;
	}

	/** FindNearestsize
	 * This works on the assumption that when the exact image size name is not available, find the nearest width with the smallest possible difference to impact the site the least.
	 */
	private function findNearestSize($sizeName)
	{

		if (! isset($this->source_metadata['sizes'][$sizeName]) || ! isset($this->target_metadata['width'])) // This can happen with non-image files like PDF.
		{
			// Check if metadata-less item is a svg file. Just the main file to replace all thumbnails since SVG's don't need thumbnails.
			if (strpos($this->target_url, '.svg') !== false) {
				$svg_file = wp_basename($this->target_url);
				return $svg_file;  // this is the relpath of the mainfile.
			}

			return false;
		}
		$old_width = $this->source_metadata['sizes'][$sizeName]['width']; // the width from size not in new image
		$new_width = $this->target_metadata['width']; // default check - the width of the main image

		$diff = abs($old_width - $new_width);
		//  $closest_file = str_replace($this->relPath, '', $this->newMeta['file']);
		$closest_file = wp_basename($this->target_metadata['file']); // mainfile as default

		foreach ($this->target_metadata['sizes'] as $sizeName => $data) {
			$thisdiff = abs($old_width - $data['width']);

			if ($thisdiff  < $diff) {
				$closest_file = $data['file'];
				if (is_array($closest_file)) {
					$closest_file = $closest_file[0];
				} // HelpScout case 709692915
				if (!empty($closest_file)) {
					$diff = $thisdiff;
					$found_metasize = true;
				}
			}
		} 

		if (empty($closest_file)) return false;

		return $closest_file;
	}

	/* Check if given content is JSON format. */
	private function isJSON($content)
	{
		if (is_array($content) || is_object($content) || is_null($content))
			return false; // can never be.

		$json = json_decode($content);
		return $json && $json != $content;
	}

	private function checkIncomplete($var)
	{
		return ($var instanceof \__PHP_Incomplete_Class);
	}
} // class
