<?php
/**
 * Amazon S3 class.
 *
 * @link  http://www.boldgrid.com
 * @since 1.0.0
 *
 * @package    Boldgrid_Backup
 * @subpackage Boldgrid_Backup/admin
 * @copyright  BoldGrid.com
 * @version    $Id$
 * @author     BoldGrid.com <wpb@boldgrid.com>
 */

use Aws\S3\S3Client;
use Aws\S3\Model\MultipartUpload\UploadBuilder;
use Aws\Common\Exception\MultipartUploadException;
use Aws\Common\Model\MultipartUpload\AbstractTransfer;

/**
 * Amazon S3 class.
 *
 * @since 1.0.0
 */
class Boldgrid_Backup_Premium_Admin_Remote_Amazon_S3 {

	/**
	 * Backups page.
	 *
	 * @since 1.0.0
	 * @var   Boldgrid_Backup_Premium_Admin_Remote_Amazon_S3_Backups_Page.
	 */
	public $backups_page;

	/**
	 * Bucket object.
	 *
	 * @since 1.0.0
	 * @var   Boldgrid_Backup_Premium_Admin_Remote_Amazon_S3_Bucket.
	 */
	public $bucket;

	/**
	 * Bucket id.
	 *
	 * @since 1.0.0
	 * @var string $bucket_id
	 */
	public $bucket_id = null;

	/**
	 * Our S3 client.
	 *
	 * @since 1.0.0
	 * @var object $client
	 */
	public $client = null;

	/**
	 * The core class object.
	 *
	 * @since 1.0.0
	 * @var Boldgrid_Backup_Admin_Core
	 */
	private $core;

	/**
	 * Errors.
	 *
	 * @since 1.5.4
	 * @var   array
	 */
	public $errors = array();

	/**
	 * Key.
	 *
	 * @since 1.0.0
	 * @var string $key
	 */
	private $key = null;

	/**
	 * An instance of Boldgrid_Backup_Premium_Admin_Core.
	 *
	 * @since 1.0.0
	 * @var   Boldgrid_Backup_Premium_Admin_Core
	 */
	private $premium_core;

	/**
	 * Retention count.
	 *
	 * @since 1.0.0
	 * @var   int $retention_count
	 */
	private $retention_count = 5;

	/**
	 * Secret, I'm not telling.
	 *
	 * @since 1.0.0
	 * @var string $secret
	 */
	private $secret = null;

	/**
	 * Title.
	 *
	 * @since 1.0.0
	 * @var   string
	 */
	public $title = 'Amazon S3';

	/**
	 * Constructor.
	 *
	 * @since 1.0.0
	 *
	 * @param Boldgrid_Backup_Admin_Core $core Core class object.
	 */
	public function __construct( $core, $premium_core ) {
		$this->core = $core;
		$this->premium_core = $premium_core;

		$settings = $this->core->settings->get_settings();

		$this->key =       ! empty( $settings['remote']['amazon_s3']['key'] )       ? $settings['remote']['amazon_s3']['key']       : $this->key;
		$this->secret =    ! empty( $settings['remote']['amazon_s3']['secret'] )    ? $settings['remote']['amazon_s3']['secret']    : $this->secret;
		$this->bucket_id = ! empty( $settings['remote']['amazon_s3']['bucket_id'] ) ? $settings['remote']['amazon_s3']['bucket_id'] : $this->create_unique_bucket();
		$this->retention_count = ! empty( $settings['remote']['amazon_s3']['retention_count'] ) ? $settings['remote']['amazon_s3']['retention_count'] : $this->retention_count;

		$this->create_unique_bucket();

		$this->backups_page = new Boldgrid_Backup_Premium_Admin_Remote_Amazon_S3_Backups_Page( $this->core, $this->premium_core );
		$this->bucket = new Boldgrid_Backup_Premium_Admin_Remote_Amazon_S3_Bucket( $this->core, $this->premium_core );
	}

	/**
	 * Add menu items.
	 *
	 * @since 1.0
	 *
	 * @return null
	 */
	public function add_menu_items() {
		$capability = 'administrator';

		add_submenu_page(
			null,
			__( 'Amazon S3 Settings', 'boldgrid-backup' ),
			__( 'Amazon S3 Settings', 'boldgrid-backup' ),
			$capability,
			'boldgrid-backup-amazon-s3',
			array(
				$this,
				'submenu_page',
			)
		);

		return;
	}

	/**
	 * Upload a backup via an ajax request.
	 *
	 * This is done via the archive details of a single archive.
	 *
	 * @since 1.0.0
	 */
	public function ajax_upload() {
		if ( ! current_user_can( 'update_plugins' ) ) {
			wp_send_json_error( __( 'Permission denied.', 'boldgrid-backup' ) );
		}

		if( ! $this->core->archive_details->validate_nonce() ) {
			wp_send_json_error( __( 'Invalid nonce.', 'boldgrid-backup' ) );
		}

		$filename = ! empty( $_POST['filename'] ) ? $_POST['filename'] : false;
		$filepath = $this->core->backup_dir->get_path_to( $filename );
		if( empty( $filename ) || ! $this->core->wp_filesystem->exists( $filepath ) ) {
			wp_send_json_error( __( 'Invalid archive filename.', 'boldgrid-backup' ) );
		}

		// @todo Temp code to get more details about any errors.
		add_action( 'shutdown', function() {
			$last_error = error_get_last();

			// If there's no error or this is not fatal, abort.
			if( empty( $last_error ) || 1 !== $last_error['type'] ) {
				return;
			}

			$message = sprintf(
				'<strong>%1$s</strong>: %2$s in %3$s on line %4$s',
				__( 'Fatal error', 'boldgrid-backup' ),
				$last_error['message'],
				$last_error['file'],
				$last_error['line']
			);

			wp_send_json_error( $message );
		});

		$upload_result = $this->upload( $filepath );

		if( true === $upload_result ) {
			wp_send_json_success( $upload_result );
		} else {
			wp_send_json_error( $upload_result );
		}
	}

	/**
	 * Create a unique bucket id.
	 *
	 * When you delete a bucket, Amazon gives you the following message:
	 * Amazon S3 buckets are unique. If you delete this bucket, you may lose the
	 * bucket name to another AWS user.
	 *
	 * @since 1.0.0
	 */
	public function create_unique_bucket() {
		$url = parse_url( get_site_url() );

		$bucket_parts[] = 'boldgrid-backup';
		$bucket_parts[] = $url['host'];

		$bucket_id = implode( '-', $bucket_parts );

		return $bucket_id;
	}

	/**
	 * Download a backup from Amazon S3 and put it in backups folder.
	 *
	 * @since 1.5.4
	 *
	 * @param  string $bucket_id
	 * @param  string $key
	 * @return bool
	 */
	public function download( $bucket_id = null, $key ) {
		$bucket_id = is_null( $bucket_id ) ? $this->bucket_id : $bucket_id;

		if( empty( $bucket_id ) || empty( $key ) ) {
			return false;
		}

		$this->set_client();

		$path = $this->core->backup_dir->get_path_to( $key );

		// Example $result: https://pastebin.com/thyw9jrh
		$result = $this->client->getObject( array(
			'Bucket' => $bucket_id,
			'Key'    => $key,
			'SaveAs' => $path,
		));

		if( empty( $result['ContentLength'] ) ) {
			return false;
		}

		// Change the timestamp of the backup file.
		$last_modified = ! empty( $result['Metadata']['last_modified'] ) ? $result['Metadata']['last_modified'] : null;
		if( ! empty( $last_modified ) ) {
			$this->core->wp_filesystem->touch( $path, $last_modified );
		}

		// If the backup file is not valid, delete it.
		$valid = $this->bucket->validate_backup( $key );
		if( ! $valid && ! empty( $this->bucket->errors ) ) {
			$this->errors = array_merge( $this->errors, $this->bucket->errors );
			$this->core->wp_filesystem->delete( $path );
		}

		$this->core->remote->post_download( $path );

		return $valid;
	}

	/**
	 * Enforce retention.
	 *
	 * @since 1.0.0
	 */
	public function enforce_retention() {
		if( empty( $this->retention_count ) ) {
			return;
		}

		$bucket_contents = $this->bucket->get( $this->bucket_id, true );

		if( empty( $bucket_contents ) ) {
			return;
		}

		// Remove files from bucket list that are not BoldGrid Backup backups.
		foreach( $bucket_contents as $key => $item ) {
			if( empty( $item['Headers']['Metadata']['is_boldgrid_backup'] ) || 'true' !== $item['Headers']['Metadata']['is_boldgrid_backup'] ) {
				unset( $bucket_contents[$key] );
			}
		}

		// Sort by timestamp desc.
		usort( $bucket_contents, function( $a, $b ){
			return $a['Headers']['Metadata']['last_modified'] < $b['Headers']['Metadata']['last_modified'] ? 1 : -1;
		});

		// Do the deleting.
		$count = 0;
		foreach( $bucket_contents as $item ) {
			$count++;
			if( $count <= $this->retention_count ) {
				continue;
			}

			$this->client->deleteObject( array(
				'Bucket' => $this->bucket_id,
				'Key'    => $item['Key'],
			));

			/**
			 * Remote file deleted due to remote retention settings.
			 *
			 * @since 1.5.3
			 */
			do_action(
				'boldgrid_backup_remote_retention_deleted',
				'Amazon S3',
				sprintf( __( 'Bucket: %1$s, Key: %2$s', 'boldgrid-backup' ), $this->bucket_id, $item['Key'] )
			);
		}

		$this->bucket->delete_transients();
	}

	/**
	 * Add Amazon S3 backups to the list of all backups.
	 *
	 * @since 1.0.0
	 */
	public function filter_get_all() {
		$bucket_contents = $this->bucket->get( $this->bucket_id, true );

		if( empty( $bucket_contents ) ) {
			return;
		}

		foreach( $bucket_contents as $item ) {
			$filename = $item['Key'];
			$is_backup = ! empty( $item['Headers']['Metadata']['is_boldgrid_backup'] ) && 'true' === $item['Headers']['Metadata']['is_boldgrid_backup'];

			if( ! $is_backup ) {
				continue;
			}

			$backup = array(
				'filename' => $filename,
				'last_modified' => $item['Headers']['Metadata']['last_modified'],
				'size' => $item['Size'],
				'location' => $this->title,
				'locations' => array(
					$this->title,
				),
			);
			$this->core->archives_all->add( $backup );
		}
	}

	/**
	 * Get the contents of a bucket.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $bucket_id
	 * @param  bool   $include_headers
	 * @return array  https://pastebin.com/uVkx8t5A
	 */
	public function get_bucket( $bucket_id, $include_headers = false ) {
		$this->set_client();
		$this->set_bucket_id( $bucket_id );

		$bucket_contents = array();

		$transient_name = sprintf(
			'boldgrid_backup_s3_bucket_%1$s%2$s',
			$include_headers ? 'w_headers_' : '',
			$this->bucket_id
		);

		$bucket_contents = get_transient( $transient_name );
		if( false !== $bucket_contents ) {
			return $bucket_contents;
		}

		// If the bucket does not exist, return an empty bucket.
		try{
			$iterator = $this->client->getIterator( 'ListObjects', array(
				'Bucket' => $this->bucket_id
			));

			foreach( $iterator as $object ) {
				if( $include_headers ) {
					$object['Headers'] = $this->get_headers( $object['Key'] );
				}

				$bucket_contents[] = $object;
			}
		} catch( Aws\S3\Exception\NoSuchBucketException $e ) {
			return array();
		}

		set_transient( $transient_name, $bucket_contents, 5 * MINUTE_IN_SECONDS );

		return $bucket_contents;
	}

	/**
	 * Get settings.
	 *
	 * @since 1.0.0
	 */
	public function get_details() {
		$settings = $this->core->settings->get_settings();

		return array(
			'title' => __( 'Amazon S3', 'boldgrid-backup' ),
			'key' => 'amazon_s3',
			'configure' => 'admin.php?page=boldgrid-backup-amazon-s3',
			'is_setup' => $this->is_setup(),
			'enabled' => ! empty( $settings['remote']['amazon_s3']['enabled'] ) && $settings['remote']['amazon_s3']['enabled'] && $this->is_setup(),
		);
	}

	/**
	 * Get the headers of an object.
	 *
	 * @since 1.0.0
	 *
	 * @param string $key
	 */
	public function get_headers( $key ) {
		$params = array(
			'Bucket' => $this->bucket_id,
			'Key' => $key,
		);

		$headers = $this->client->headObject( $params );

		return $headers->toArray();
	}

	/**
	 * Determine if a file exists in a bucket.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $filepath
	 * @return bool
	 */
	public function in_bucket( $bucket_id, $filepath ) {
		$this->set_client();
		if( empty( $this->client ) ) {
			return false;
		}

		$bucket_contents = $this->bucket->get( $bucket_id );
		$filename = basename( $filepath );

		foreach( $bucket_contents as $item ) {
			if( $item['Key'] === $filename ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Set our client.
	 *
	 * @since 1.0.0
	 *
	 * @param string $key
	 * @param string $secret
	 */
	public function set_client( $key = null, $secret = null ) {
		$key = empty( $key ) ? $this->key : $key;
		$secret = empty( $secret ) ? $this->secret : $secret;

		if( empty( $key ) || empty( $secret ) ) {
			return false;
		}

		$credentials = array(
			'key'    => $key,
			'secret' => $secret,
		);

		$this->client = S3Client::factory( array(
			'credentials' => $credentials,
		));
	}

	/**
	 * Return data about a particular archive in Amazon S3.
	 *
	 * For example, if you're looking at a single backup, we need to know if it
	 * already exists in our Amazon S3 account.
	 *
	 * This method will return an array of information useful to the single
	 * archive page.
	 *
	 * @since  1.0.0
	 */
	public function single_archive_remote_option( $filepath ) {

		$allow_upload = $this->is_setup();
		$uploaded = $allow_upload && $this->in_bucket( null, $filepath );

		$this->core->archive_details->remote_storage_li[] = array(
			'id' => 'amazon_s3',
			'title' => 'Amazon S3',
			'uploaded' => $uploaded,
			'allow_upload' => $allow_upload,
			'is_setup' => $allow_upload,
		);
	}

	/**
	 * Generate the submenu page for our Amazon S3 Settings page.
	 *
	 * @since 1.0.0
	 */
	public function submenu_page() {
		wp_enqueue_style( 'boldgrid-backup-admin-hide-all' );

		$this->submenu_page_save();

		$settings = $this->core->settings->get_settings();

		$key = ! empty( $settings['remote']['amazon_s3']['key'] ) ? $settings['remote']['amazon_s3']['key'] : null;
		$secret = ! empty( $settings['remote']['amazon_s3']['secret'] ) ? $settings['remote']['amazon_s3']['secret'] : null;
		$bucket_id = ! empty( $settings['remote']['amazon_s3']['bucket_id'] ) ? $settings['remote']['amazon_s3']['bucket_id'] : $this->bucket_id;
		$retention_count = ! empty( $settings['remote']['amazon_s3']['retention_count'] ) ? $settings['remote']['amazon_s3']['retention_count'] : $this->retention_count;

		include BOLDGRID_BACKUP_PREMIUM_PATH . '/admin/partials/remote/amazon-s3.php';
	}

	/**
	 * Process the user's request to update their Amazon S3 settings.
	 *
	 * @since 1.0.0
	 */
	public function submenu_page_save() {
		if ( ! current_user_can( 'update_plugins' ) ) {
			return false;
		}

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

		$this->bucket->delete_transients();

		$settings = $this->core->settings->get_settings();
		if( ! isset( $settings['remote']['amazon_s3'] ) || ! is_array( $settings['remote']['amazon_s3'] ) ) {
			$settings['remote']['amazon_s3'] = array();
		}

		/*
		 * If the user has requested to delete all their settings, do that now
		 * and return.
		 */
		if( __( 'Delete settings', 'boldgrid-backup' ) === $_POST['submit'] ) {
			$settings['remote']['amazon_s3'] = array();
			update_site_option( 'boldgrid_backup_settings', $settings );

			$this->key = null;
			$this->secret = null;
			$this->bucket_id = null;
			$this->retention_count = null;

			do_action( 'boldgrid_backup_notice', __( 'Settings saved.', 'boldgrid-backup' ), 'notice updated is-dismissible' );
			return;
		}

		$errors = array();

		$key =       ! empty( $_POST['key'] )       ? $_POST['key']       : null;
		$secret =    ! empty( $_POST['secret'] )    ? $_POST['secret']    : null;
		$bucket_id = ! empty( $_POST['bucket_id'] ) ? $_POST['bucket_id'] : null;
		$retention_count = ! empty( $_POST['retention_count'] ) && is_numeric( $_POST['retention_count'] ) ? $_POST['retention_count'] : $this->retention_count;

		$valid_credentials = $this->is_valid_credentials( $key, $secret );

		/*
		 * Check if we have a valid bucket name.
		 *
		 * If we don't have a valid client, set that first.
		 */
		if( is_null( $this->client ) ) {
			$this->set_client();
		}
		$valid_bucket = $this->client->isValidBucketName( $bucket_id );

		if( $valid_credentials ) {
			$settings['remote']['amazon_s3']['key'] = $key;
			$settings['remote']['amazon_s3']['secret'] = $secret;
			$this->key = $key;
			$this->secret = $secret;
		} else {
			$errors[] = __( 'Invalid Access Key Id and / or Secret Access Key.', 'boldgrid-backup' );
		}

		if( $valid_bucket ) {
			$settings['remote']['amazon_s3']['bucket_id'] = $bucket_id;
			$this->bucket_id = $bucket_id;
		} else {
			$errors[] = __( 'Invalid Bucket ID. Please only use lowercase letters, numbers, and hypens. Bucket must also be between 3 and 63 characters long.', 'boldgrid-inspirations' );
		}

		$settings['remote']['amazon_s3']['retention_count'] = $retention_count;

		if( ! empty( $errors ) ) {
			do_action( 'boldgrid_backup_notice', implode( '<br /><br />', $errors ) );
		} else {
			update_site_option( 'boldgrid_backup_settings', $settings );
			do_action( 'boldgrid_backup_notice', __( 'Settings saved.', 'boldgrid-backup' ), 'notice updated is-dismissible' );
		}
	}

	/**
	 * Set our bucket id.
	 *
	 * @since 1.0.0
	 *
	 * @param string $bucket_id
	 */
	public function set_bucket_id( $bucket_id ) {
		if( empty( $bucket_id ) ) {
			return;
		}

		$this->bucket_id = $bucket_id;
	}

	/**
	 * Upload a backup file.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $filepath
	 * @return mixed  True on success, error message on failure.
	 */
	public function upload( $filepath ) {
		$this->set_client();

		if( ! $this->core->wp_filesystem->exists( $filepath ) ) {
			return sprintf( __( 'Failed to upload, filepath does not exist: %1$s', 'boldgrid-backup'), $filepath );
		}

		$key = basename( $filepath );

		/*
		 * When files are uploaded to Amazon S3, the LastModified is the time
		 * the file was uploaded, not the time the file was last modified. When
		 * we enforce retention, we'll need to know when the backup archive was
		 * created, not when it was uploaded to Amazon S3.
		 */
		$archive_data = $this->core->archive_log->get_by_zip( $filepath );
		$last_modified = ! empty( $archive_data['lastmodunix'] ) ? $archive_data['lastmodunix'] : $this->core->wp_filesystem->mtime( $filepath );

		// Poll the bucket until it is accessible.
		$this->client->createBucket( array(
			'Bucket' => $this->bucket_id,
		));
		$this->client->waitUntil( 'BucketExists', array(
			'Bucket' => $this->bucket_id,
		));

		try {
			$uploader = UploadBuilder::newInstance()
				->setClient( $this->client )
				->setSource( $filepath )
				->setBucket( $this->bucket_id )
				->setKey( $key )
				->setOption( 'ACL', 'private' )
				->setOption( 'Metadata', array(
					'is_boldgrid_backup' => 'true',
					'last_modified' => $last_modified,
				))
				->setConcurrency(3)
				->build();
		} catch ( Exception $e ) {
			return __( 'Failed to initialize', 'boldgrid-backup' );
		}

		try {
			$uploader->upload();
			$this->enforce_retention();
			$this->bucket->delete_transients();
		} catch( MultipartUploadException $e ) {
			$uploader->abort();
			return __( 'Failed to upload.', 'boldgrid-inspirations' );
		}

		/**
		 * File uploaded to remote storage location.
		 *
		 * @since 1.5.3
		 *
		 * @param string Amazon S3
		 * @param string $filepath
		 */
		do_action( 'boldgrid_backup_remote_uploaded', 'Amazon S3', $filepath );

		return true;
	}

	/**
	 * Upload a file.
	 *
	 * The jobs queue will call this method to upload a file.
	 *
	 * @since 1.0.0
	 *
	 * @param string $filepath
	 */
	public function upload_post_archiving( $filepath ) {
		$success = $this->upload( $filepath );

		return $success;
	}

	/**
	 * Determine if Amazon S3 is setup properly.
	 *
	 * Hook into "boldgrid_backup_is_setup_amazon_s3".
	 */
	public function is_setup() {
		return $this->is_valid_credentials( $this->key, $this->secret ) && $this->client->isValidBucketName( $this->bucket_id );
	}

	/**
	 * Determine if Amazon S3 is setup properly.
	 *
	 * This method is ran within an ajax request.
	 *
	 * @return array
	 */
	public function is_setup_ajax() {
		if ( ! current_user_can( 'update_plugins' ) ) {
			wp_send_json_error( __( 'Permission denied.', 'boldgrid-backup' ) );
		}

		if( ! check_ajax_referer( 'boldgrid_backup_settings', 'security', false ) ) {
			wp_send_json_error( __( 'Invalid nonce.', 'boldgrid-backup' ) );
		}

		$settings = $this->core->settings->get_settings();

		$location = $this->get_details();
		$tr = include BOLDGRID_BACKUP_PATH . '/admin/partials/settings/storage-location.php';

		if( $this->is_setup() ) {
			wp_send_json_success( $tr );
		} else {
			wp_send_json_error( $tr );
		}
	}

	/**
	 * Determine if credentials are valid.
	 *
	 * @since 1.0.0
	 *
	 * @param  string $key
	 * @param  string $secret
	 * @return bool
	 */
	public function is_valid_credentials( $key, $secret ) {
		if( empty( $key ) || empty( $secret ) ) {
			return false;
		}

		$this->set_client( $key, $secret );

		try {
			$this->client->listBuckets();
			return true;
		} catch( Exception $e ) {
			// Deubg.
			// echo '<pre>'; echo get_class( $e ); echo '</pre>';
			return false;
		}
	}

	/**
	 * Actions to take after a backup file has been generated.
	 *
	 * @since 1.0.0
	 *
	 * @param array $info
	 */
	public function post_archive_files( $info ) {

		/*
		 * We only want to add this to the jobs queue if we're in the middle of
		 * an automatic backup. If the user simply clicked on "Backup site now",
		 * we don't want to automatically send the backup to Amazon, there's a
		 * button for that.
		 */
		if( ! $this->core->doing_cron ) {
			return;
		}

		if( ! $this->core->remote->is_enabled( 'amazon_s3' ) || $info['dryrun'] || ! $info['save'] ) {
			return;
		}

		$args = array(
			'filepath' => $info['filepath'],
			'action' => 'boldgrid_backup_amazon_s3_upload_post_archive',
			'action_data' => $info['filepath'],
			'action_title' => __( 'Upload backup file to Amazon S3', 'boldgrid-backup' ),
		);

		$this->core->jobs->add( $args );
	}

	/**
	 * Register Amazon S3 as a storage location.
	 *
	 * When you go to the BoldGrid Backup settings page and see a list of
	 * storage providers, each of those storage providers needs to hook into
	 * the "boldgrid_backup_register_storage_location" filter and add
	 * themselves.
	 *
	 * @since 1.0.0
	 */
	public function register_storage_location( $storage_locations ) {
		$storage_locations[] = $this->get_details();

		return $storage_locations;
	}
}
