<?php
/**
 * Implements backup/restore functions.
 *
 * @link https://ewww.io
 * @package EIO
 */

namespace EWWW;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Backup & Restore images from both local and cloud locations.
 */
class Backup extends Base {

	/**
	 * An error from a restore operation.
	 *
	 * @access protected
	 * @var string $error_message
	 */
	protected $error_message = '';

	/**
	 * A list of exclusions.
	 *
	 * @access protected
	 * @var array $exclusions
	 */
	protected $exclusions = array();

	/**
	 * Backup mode (local/cloud).
	 *
	 * @var string $backup_mode
	 */
	protected $backup_mode = '';

	/**
	 * Backup location.
	 *
	 * @var string $backup_dir
	 */
	protected $backup_dir = '';

	/**
	 * Backup location for media uploads.
	 *
	 * @var string $backup_uploads_dir
	 */
	protected $backup_uploads_dir = '';

	/**
	 * Backup location for images outside the wp-content directory.
	 *
	 * @var string $backup_root_dir
	 */
	protected $backup_root_dir = '';

	/**
	 * Register (once) actions and filters for Backup and Restore.
	 */
	public function __construct() {
		global $eio_backup;
		if ( \is_object( $eio_backup ) ) {
			return $eio_backup;
		}
		parent::__construct();
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		if ( 'local' === $this->get_option( 'ewww_image_optimizer_backup_files' ) ) {
			$this->backup_mode = 'local';
			// Sub-folders of the content directory will be stored directly in the image-backup/ folder.
			$this->backup_dir = \trailingslashit( $this->content_dir ) . \trailingslashit( 'image-backup' );
			// Sub-folders of the content directory will be stored directly in the image-backup/ folder.
			$this->backup_uploads_dir = \trailingslashit( $this->backup_dir ) . \trailingslashit( 'uploads' );
			// Folders outside the content dir, and relative to ABSPATH will be stored in the root/ directory.
			$this->backup_root_dir = \trailingslashit( $this->backup_dir ) . \trailingslashit( 'root' );
		} elseif ( $this->get_option( 'ewww_image_optimizer_cloud_key' ) && $this->get_option( 'ewww_image_optimizer_backup_files' ) ) {
			$this->backup_mode = 'cloud';
		}

		// AJAX action hook for manually restoring a single image from cloud/local backups.
		\add_action( 'wp_ajax_ewww_manual_image_restore_single', array( $this, 'restore_single_image_handler' ) );
		\add_action( 'ewww_image_optimizer_pre_optimization', array( $this, 'store_local_backup' ) );

		// Makes sure the plugin does not optimize anything in the backups folder.
		add_filter( 'ewww_image_optimizer_bypass', array( $this, 'ignore_backup_dir' ), 10, 2 );

		$this->exclusions = array(
			$this->content_dir,
			'/wp-admin/',
			'/wp-includes/',
			'/cache/',
			'/dynamic/', // Nextgen dynamic images.
		);
		$this->exclusions = \apply_filters( 'ewww_image_optimizer_backup_exclusions', $this->exclusions );
	}

	/**
	 * Gets the error message from the most recent restore operation, if any.
	 *
	 * @return string An error message.
	 */
	public function get_error() {
		return (string) $this->error_message;
	}

	/**
	 * Sets the error message for restore operations.
	 *
	 * @param string $error An error message.
	 */
	public function throw_error( $error ) {
		if ( \is_string( $error ) ) {
			$this->error_message = \sanitize_text_field( $error );
		}
	}

	/**
	 * Check if a file is within the backup folder and if so, prevent optimization.
	 *
	 * @param bool   $skip Whether optimization will be skipped, defaults to false.
	 * @param string $file The filename to be checked.
	 * @return bool True to skip optimization, false to let it through.
	 */
	public function ignore_backup_dir( $skip, $file ) {
		if ( $file && $this->backup_dir && false !== \strpos( $file, $this->backup_dir ) ) {
			return true;
		}
		return $skip;
	}

	/**
	 * Checks whether a file is in the uploads dir, content dir, or within the ABSPATH/root.
	 *
	 * This helps to deal with cases where folks have upload and/or content dirs outside ABSPATH.
	 *
	 * @param string $file The filename to backup.
	 * @return string The backup location for the file.
	 */
	public function get_backup_location( $file ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		if ( \ewww_image_optimizer_stream_wrapped( $file ) || 'local' !== $this->backup_mode ) {
			$this->debug_message( "$file is stream wrapped or {$this->backup_mode} backup_mode !== local" );
			return '';
		}
		$upload_dir = \wp_get_upload_dir();
		$upload_dir = \trailingslashit( \realpath( $upload_dir['basedir'] ) );
		if ( $upload_dir && \strpos( $file, $upload_dir ) === 0 ) {
			$this->debug_message( 'using ' . $this->backup_uploads_dir );
			return \str_replace( $upload_dir, $this->backup_uploads_dir, $file );
		}
		$this->debug_message( "$file is not in upload dir ($upload_dir)" );
		$content_dir = \trailingslashit( \realpath( WP_CONTENT_DIR ) );
		if ( $content_dir && \strpos( $file, $content_dir ) === 0 ) {
			$this->debug_message( 'using ' . $this->backup_dir );
			return \str_replace( $content_dir, $this->backup_dir, $file );
		}
		$this->debug_message( "$file is not in content dir ($content_dir)" );
		$wp_dir = \trailingslashit( \realpath( ABSPATH ) );
		if ( $wp_dir && \strpos( $file, $wp_dir ) === 0 ) {
			$this->debug_message( 'using ' . $this->backup_root_dir );
			return \str_replace( $wp_dir, $this->backup_root_dir, $file );
		}
		$this->debug_message( "$file is not in root dir/ABSPATH ($wp_dir)" );
		return '';
	}

	/**
	 * Checks to see if a backup is available for a given file.
	 *
	 * @param string $file The image file to search for a backup.
	 * @param array  $record The database record for the file. Optional.
	 * @return bool True if a backup is available, false otherwise.
	 */
	public function is_backup_available( $file, $record = false ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		$file = \ewww_image_optimizer_absolutize_path( $file );
		if ( 'local' === $this->backup_mode ) {
			\clearstatcache();
			$backup_file = $this->get_backup_location( $file );
			return $this->is_file( $backup_file );
		} elseif ( 'cloud' === $this->backup_mode ) {
			if ( ! $record || ! isset( $record['backup'] ) || ! isset( $record['updated'] ) ) {
				$record = \ewww_image_optimizer_find_already_optimized( $file );
			}
			if ( $record && $this->is_iterable( $record ) && ! empty( $record['backup'] ) && ! empty( $record['updated'] ) ) {
				$updated_time = $record['updated'];
				if ( ! is_numeric( $record['updated'] ) ) {
					$updated_time = \strtotime( $record['updated'] );
				}
				$this->debug_message( "checking if $updated_time (from {$record['updated']}) is too far gone" );
				if ( DAY_IN_SECONDS * 30 + $updated_time > \time() ) {
					$this->debug_message( 'backup still good!' );
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Backup a file to local or cloud storage.
	 *
	 * @param string $file Name of the file to backup.
	 */
	public function backup_file( $file ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		$this->debug_message( "file: $file " );
		foreach ( $this->exclusions as $exclusion ) {
			if ( false !== \strpos( $file, $exclusion ) ) {
				$this->debug_message( "matched $exclusion, no backup for you!" );
				return;
			}
		}
		if ( 'local' === $this->backup_mode ) {
			$this->store_local_backup( $file );
		} elseif ( 'cloud' === $this->backup_mode ) {
			$this->store_cloud_backup( $file );
		}
	}

	/**
	 * Copy a file to the backup location.
	 *
	 * @param string $file Name of the file to backup.
	 */
	public function store_local_backup( $file ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		if ( 'local' !== $this->backup_mode ) {
			$this->debug_message( 'not using local backups' );
			return;
		}
		if ( ! $this->is_file( $file ) || ! $this->is_readable( $file ) ) {
			$this->debug_message( "$file is not a file or not readable" );
			return;
		}
		// Even though these are checked in Backup::backup_file(), this method can be run directly, which bypasses that check.
		foreach ( $this->exclusions as $exclusion ) {
			if ( false !== \strpos( $file, $exclusion ) ) {
				$this->debug_message( "matched $exclusion, no backup for you!" );
				return;
			}
		}
		if ( apply_filters( 'ewww_image_optimizer_skip_local_backup', false, $file ) ) {
			$this->debug_message( 'skipping local backup due to filter' );
			return;
		}
		$backup_file = $this->get_backup_location( $file );
		if ( ! $backup_file || $backup_file === $file ) {
			$this->debug_message( "$backup_file is not a valid backup location for $file" );
			return;
		}
		\clearstatcache();
		if ( $this->is_file( $backup_file ) ) {
			$this->debug_message( "$backup_file already exists, not overwriting" );
			return;
		}
		\wp_mkdir_p( \dirname( $backup_file ) );
		\clearstatcache();
		if ( ! \is_writable( \dirname( $backup_file ) ) ) {
			$this->debug_message( \dirname( $backup_file ) . ' is not writable' );
			return;
		}
		$this->debug_message( "backing up $file to $backup_file" );
		\copy( $file, $backup_file );
		if ( $this->filesize( $file ) !== $this->filesize( $backup_file ) ) {
			// In order to not store bogus files.
			$this->delete_file( $backup_file );
		}
	}

	/**
	 * Send a file to the API for backup.
	 *
	 * @param string $file Name of the file to backup.
	 */
	protected function store_cloud_backup( $file ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		\ewww_image_optimizer_cloud_backup( $file );
	}

	/**
	 * Restore an image from local or cloud storage.
	 *
	 * @global object $wpdb
	 *
	 * @param int|array $image The db record/ID of the image to restore.
	 * @return bool True if the image was restored successfully.
	 */
	public function restore_file( $image ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		global $wpdb;
		$this->error_message = '';
		if ( ! \is_array( $image ) && ! empty( $image ) && \is_numeric( $image ) ) {
			$image = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT id,path,backup FROM $wpdb->ewwwio_images WHERE id = %d",
					$image
				),
				ARRAY_A
			);
		}
		if ( ! empty( $image['path'] ) ) {
			$image['path'] = \ewww_image_optimizer_absolutize_path( $image['path'] );
		}
		if ( empty( $image['path'] ) ) {
			return false;
		}
		if ( 'local' === $this->backup_mode ) {
			return $this->restore_from_local( $image );
		} elseif ( 'cloud' === $this->backup_mode ) {
			return $this->restore_from_cloud( $image );
		}
		return false;
	}

	/**
	 * Restore a file from a local backup location.
	 *
	 * @param array $image The db record of the image to restore.
	 */
	protected function restore_from_local( $image ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		if ( 'local' !== $this->backup_mode ) {
			return false;
		}
		$file = $image['path'];
		if ( ! \is_writable( \dirname( $file ) ) ) {
			$this->debug_message( "$file (or the parent dir) is not writable" );
			/* translators: %s: An image filename */
			$this->error_message = \sprintf( \__( '%s is not writable.', 'ewww-image-optimizer' ), $file );
			return false;
		}
		$backup_file = $this->get_backup_location( $file );
		if ( ! $backup_file || $backup_file === $file ) {
			$this->debug_message( "$backup_file is not a valid backup location for $file" );
			/* translators: %s: An image filename */
			$this->error_message = \sprintf( \__( 'Could not determine backup location for %s.', 'ewww-image-optimizer' ), $file );
			return false;
		}
		\clearstatcache();
		if ( ! $this->is_file( $backup_file ) ) {
			$this->debug_message( "$backup_file does not exist" );
			/* translators: %s: An image filename */
			$this->error_message = \sprintf( \__( 'No backup available for %s.', 'ewww-image-optimizer' ), $file );
			return false;
		}
		if ( \ewww_image_optimizer_mimetype( $file, 'i' ) !== \ewww_image_optimizer_mimetype( $backup_file, 'i' ) ) {
			$this->debug_message( "$backup_file is different type than $file " . \ewww_image_optimizer_mimetype( $backup_file, 'i' ) . ' vs. ' . \ewww_image_optimizer_mimetype( $file, 'i' ) );
			/* translators: %s: An image filename */
			$this->error_message = \sprintf( \__( 'Backup file for %s has the wrong mime type.', 'ewww-image-optimizer' ), $file );
			return false;
		}
		$filesize = $this->filesize( $file );
		$backsize = $this->filesize( $backup_file );
		if ( $filesize && $filesize === $backsize ) {
			// $this->delete_file( $backup_file );
			// return true; // Because restore not needed, already done!
		}
		$this->debug_message( "restoring $file from $backup_file" );
		copy( $backup_file, $file );
		if ( $this->filesize( $file ) === $this->filesize( $backup_file ) ) {
			$this->debug_message( 'restore success, checking for .webp variant and resetting db record' );
			if ( $this->is_file( $file . '.webp' ) && \is_writable( $file . '.webp' ) ) {
				$this->debug_message( "removing $file.webp" );
				$this->delete_file( $file . '.webp' );
			}
			/* $this->delete_file( $backup_file ); */
			global $wpdb;
			// Reset the image record.
			$wpdb->query( $wpdb->prepare( "UPDATE $wpdb->ewwwio_images SET image_size = 0, updates = 0, updated=updated, level = 0, resized_width = 0, resized_height = 0, resize_error = 0, webp_size = 0, webp_error = 0 WHERE id = %d", $image['id'] ) );
			return true;
		}
		$this->debug_message( 'restore not confirmed, filesize does not match: ' . $this->filesize( $file ) . ' vs. ' . $this->filesize( $backup_file ) );
		/* translators: %s: An image filename */
		$this->error_message = \sprintf( \__( 'Restore attempted for %s, but could not be confirmed.', 'ewww-image-optimizer' ), $file );
		return false;
	}

	/**
	 * Send a file to the API for backup.
	 *
	 * @param int|array $image The db record/ID of the image to restore.
	 * @return bool True if the image was restored successfully.
	 */
	protected function restore_from_cloud( $image ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		return \ewww_image_optimizer_cloud_restore_single_image( $image );
	}

	/**
	 * Delete the local backup file. Used when deleting an attachment.
	 *
	 * @param array $file The filename of the image for which we should remove the backup.
	 */
	public function delete_local_backup( $file ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		if ( ! $file ) {
			return;
		}
		if ( 'local' !== $this->backup_mode ) {
			return;
		}
		$backup_file = $this->get_backup_location( $file );
		if ( ! $backup_file || $backup_file === $file ) {
			return;
		}
		\clearstatcache();
		if ( ! $this->is_file( $backup_file ) || ! \is_writable( $backup_file ) ) {
			return;
		}
		$this->delete_file( $backup_file );
	}

	/**
	 * Restore an attachment from the API or local backups.
	 *
	 * @global object $wpdb
	 *
	 * @param int    $id The attachment id number.
	 * @param string $gallery Optional. The gallery from whence we came. Default 'media'.
	 * @param array  $meta Optional. The image metadata from the postmeta table.
	 * @return array The altered meta (if size differs), or the original value passed along.
	 */
	public function restore_backup_from_meta_data( $id, $gallery = 'media', $meta = array() ) {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		global $wpdb;
		$images = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id,path,resize,backup FROM $wpdb->ewwwio_images WHERE attachment_id = %d AND gallery = %s",
				$id,
				$gallery
			),
			ARRAY_A
		);
		foreach ( $images as $image ) {
			if ( ! empty( $image['path'] ) ) {
				$image['path'] = \ewww_image_optimizer_absolutize_path( $image['path'] );
			}
			$this->restore_file( $image );
			if ( 'media' === $gallery && 'full' === $image['resize'] && ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) {
				list( $width, $height ) = $this->getimagesize( $image['path'] );
				if ( (int) $width !== (int) $meta['width'] || (int) $height !== (int) $meta['height'] ) {
					$meta['height'] = $height;
					$meta['width']  = $width;
				}
			}
		}
		if ( 'media' === $gallery ) {
			\remove_filter( 'wp_update_attachment_metadata', 'ewww_image_optimizer_update_filesize_metadata', 9 );
			$meta = \ewww_image_optimizer_update_filesize_metadata( $meta, $id );
		}
		if ( $this->s3_uploads_enabled() ) {
			\ewww_image_optimizer_remote_push( $meta, $id );
			$this->debug_message( 're-uploading to S3(_Uploads)' );
		}
		return $meta;
	}

	/**
	 * Handle the AJAX call for a single image restore.
	 */
	public function restore_single_image_handler() {
		$this->debug_message( '<b>' . __METHOD__ . '()</b>' );
		// Check permissions of current user.
		$permissions = \apply_filters( 'ewww_image_optimizer_manual_permissions', '' );
		if ( ! \current_user_can( $permissions ) ) {
			// Display error message if insufficient permissions.
			$this->ob_clean();
			\wp_die( \wp_json_encode( array( 'error' => \esc_html__( 'You do not have permission to optimize images.', 'ewww-image-optimizer' ) ) ) );
		}
		// Make sure we didn't accidentally get to this page without an attachment to work on.
		if ( empty( $_REQUEST['ewww_image_id'] ) ) {
			// Display an error message since we don't have anything to work on.
			$this->ob_clean();
			\wp_die( \wp_json_encode( array( 'error' => \esc_html__( 'No image ID was provided.', 'ewww-image-optimizer' ) ) ) );
		}
		if (
			empty( $_REQUEST['ewww_wpnonce'] ) ||
			(
				! \wp_verify_nonce( \sanitize_key( $_REQUEST['ewww_wpnonce'] ), 'ewww-image-optimizer-tools' ) &&
				! \wp_verify_nonce( \sanitize_key( $_REQUEST['ewww_wpnonce'] ), 'ewww-image-optimizer-bulk' )
			)
		) {
			$this->ob_clean();
			\wp_die( \wp_json_encode( array( 'error' => \esc_html__( 'Access token has expired, please reload the page.', 'ewww-image-optimizer' ) ) ) );
		}
		\session_write_close();
		$image = (int) $_REQUEST['ewww_image_id'];
		$this->debug_message( "attempting restore for $image" );
		if ( $this->restore_file( $image ) ) {
			$this->ob_clean();
			\wp_die( \wp_json_encode( array( 'success' => 1 ) ) );
		}
		$this->ob_clean();
		\wp_die( \wp_json_encode( array( 'error' => \esc_html__( 'Unable to restore image.', 'ewww-image-optimizer' ) ) ) );
	}
}

global $eio_backup;
$eio_backup = new Backup();
