<?php
namespace W3TC;

if ( !defined( 'W3TC_SKIPLIB_AWS' ) ) {
	require_once W3TC_DIR . '/vendor/autoload.php';
}

/**
 * CDN engine for S3 push type
 */
class CdnEngine_S3 extends CdnEngine_Base {
	private $api;

	/**
	 * Regions list.
	 *
	 * @link https://docs.aws.amazon.com/general/latest/gr/rande.html
	 *
	 * @return array
	 */
	static public function regions_list() {
		return array(
			'us-east-1'      => __( 'US East (N. Virginia)', 'w3-total-cache' ),
			'us-east-2'      => __( 'US East (Ohio)', 'w3-total-cache' ),
			'us-west-1'      => __( 'US West (N. California)', 'w3-total-cache' ),
			'us-west-2'      => __( 'US West (Oregon)', 'w3-total-cache' ),
			'af-south-1'     => __( 'Africa (Cape Town)', 'w3-total-cache' ),
			'ap-east-1'      => __( 'Asia Pacific (Hong Kong)', 'w3-total-cache' ),
			'ap-northeast-1' => __( 'Asia Pacific (Tokyo)', 'w3-total-cache' ),
			'ap-northeast-2' => __( 'Asia Pacific (Seoul)', 'w3-total-cache' ),
			'ap-northeast-3' => __( 'Asia Pacific (Osaka-Local)', 'w3-total-cache' ),
			'ap-south-1'     => __( 'Asia Pacific (Mumbai)', 'w3-total-cache' ),
			'ap-southeast-1' => __( 'Asia Pacific (Singapore)', 'w3-total-cache' ),
			'ap-southeast-2' => __( 'Asia Pacific (Sydney)', 'w3-total-cache' ),
			'ca-central-1'   => __( 'Canada (Central)', 'w3-total-cache' ),
			'cn-north-1'     => __( 'China (Beijing)', 'w3-total-cache' ),
			'cn-northwest-1' => __( 'China (Ningxia)', 'w3-total-cache' ),
			'eu-central-1'   => __( 'Europe (Frankfurt)', 'w3-total-cache' ),
			'eu-north-1'     => __( 'Europe (Stockholm)', 'w3-total-cache' ),
			'eu-south-1'     => __( 'Europe (Milan)', 'w3-total-cache' ),
			'eu-west-1'      => __( 'Europe (Ireland)', 'w3-total-cache' ),
			'eu-west-2'      => __( 'Europe (London)', 'w3-total-cache' ),
			'eu-west-3'      => __( 'Europe (Paris)', 'w3-total-cache' ),
			'me-south-1'     => __( 'Middle East (Bahrain)', 'w3-total-cache' ),
			'sa-east-1'      => __( 'South America (São Paulo)', 'w3-total-cache' ),
		);
	}

	public function __construct( $config = array() ) {
		$config = array_merge( array(
				'key' => '',
				'secret' => '',
				'bucket' => '',
				'bucket_location' => '',
				'cname' => array(),
			), $config );

		parent::__construct( $config );
	}

	/**
	 * Formats URL
	 */
	function _format_url( $path ) {
		$domain = $this->get_domain( $path );

		if ( $domain ) {
			$scheme = $this->_get_scheme();

			// it does not support '+', requires '%2B'
			$path = str_replace( '+', '%2B', $path );
			$url = sprintf( '%s://%s/%s', $scheme, $domain, $path );

			return $url;
		}

		return false;
	}

	/**
	 * Inits S3 object
	 *
	 * @param string  $error
	 * @return boolean
	 */
	public function _init() {
		if ( !is_null( $this->api ) ) {
			return;
		}

		if ( empty( $this->_config['bucket'] ) ) {
			throw new \Exception( 'Empty bucket.' );
		}

		if ( empty( $this->_config['key'] ) && empty( $this->_config['secret'] ) ) {
			$credentials = \Aws\Credentials\CredentialProvider::defaultProvider();
		} else {
			if ( empty( $this->_config['key'] ) ) {
				throw new \Exception( 'Empty access key.' );
			}

			if ( empty( $this->_config['secret'] ) ) {
				throw new \Exception( 'Empty secret key.' );
			}

			$credentials = new \Aws\Credentials\Credentials(
				$this->_config['key'],
				$this->_config['secret'] );
		}

		$this->api = new \Aws\S3\S3Client( array(
				'credentials' => $credentials,
				'region' => $this->_config['bucket_location'],
				'version' => '2006-03-01',
				'use_arn_region' => true,
			)
		);
	}

	/**
	 * Uploads files to S3
	 *
	 * @param array   $files
	 * @param array   $results
	 * @param boolean $force_rewrite
	 * @return boolean
	 */
	public function upload( $files, &$results, $force_rewrite = false,
		$timeout_time = NULL ) {
		$error = null;

		try {
			$this->_init();
		} catch ( \Exception $ex ) {
			$results = $this->_get_results( $files, W3TC_CDN_RESULT_HALT, $ex->getMessage() );
			return false;
		}

		foreach ( $files as $file ) {
			$local_path = $file['local_path'];
			$remote_path = $file['remote_path'];

			// process at least one item before timeout so that progress goes on
			if ( !empty( $results ) ) {
				if ( !is_null( $timeout_time ) && time() > $timeout_time ) {
					return 'timeout';
				}
			}

			$results[] = $this->_upload( $file, $force_rewrite );

			if ( $this->_config['compression'] && $this->_may_gzip( $remote_path ) ) {
				$file['remote_path_gzip'] = $remote_path . $this->_gzip_extension;
				$results[] = $this->_upload_gzip( $file, $force_rewrite );
			}
		}

		return !$this->_is_error( $results );
	}

	/**
	 * Uploads single file to S3
	 *
	 * @param array   CDN file array
	 * @param boolean $force_rewrite
	 * @return array
	 */
	private function _upload( $file, $force_rewrite = false ) {
		$local_path = $file['local_path'];
		$remote_path = $file['remote_path'];

		if ( !file_exists( $local_path ) ) {
			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_ERROR, 'Source file not found.', $file );
		}

		try {
			if ( !$force_rewrite ) {
				try {
					$info = $this->api->headObject( array(
						'Bucket' => $this->_config['bucket'],
						'Key' => $remote_path )
					);

					$hash = '"' . @md5_file( $local_path ) . '"';
					$s3_hash = ( isset( $info['ETag'] ) ? $info['ETag'] : '' );

					if ( $hash === $s3_hash ) {
						return $this->_get_result( $local_path, $remote_path,
							W3TC_CDN_RESULT_OK, 'Object up-to-date.', $file );
					}
				} catch ( \Aws\Exception\AwsException $ex ) {
					if ( $ex->getAwsErrorCode() == 'NotFound' ) {
					} else {
						throw $ex;
					}
				}
			}

			$headers = $this->get_headers_for_file( $file );
			$result = $this->_put_object( array(
					'Key' => $remote_path,
					'SourceFile' => $local_path,
				), $headers
			);

			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_OK, 'OK', $file );
		} catch ( \Exception $ex ) {
			$error = sprintf( 'Unable to put object (%s).', $ex->getMessage() );

			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_ERROR, $error, $file );
		}
	}

	/**
	 * Uploads gzip version of file
	 */
	private function _upload_gzip( $file, $force_rewrite = false ) {
		$local_path = $file['local_path'];
		$remote_path = $file['remote_path_gzip'];

		if ( !function_exists( 'gzencode' ) ) {
			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_ERROR, "GZIP library doesn't exist.", $file );
		}

		if ( !file_exists( $local_path ) ) {
			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_ERROR, 'Source file not found.', $file );
		}

		$contents = @file_get_contents( $local_path );

		if ( $contents === false ) {
			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_ERROR, 'Unable to read file.', $file );
		}

		$data = gzencode( $contents );

		try {
			if ( !$force_rewrite ) {
				try {
					$info = $this->api->headObject( array(
						'Bucket' => $this->_config['bucket'],
						'Key' => $remote_path )
					);

					$hash = '"' . md5( $data ) . '"';
					$s3_hash = ( isset( $info['ETag'] ) ? $info['ETag'] : '' );

					if ( $hash === $s3_hash ) {
						return $this->_get_result( $local_path, $remote_path,
							W3TC_CDN_RESULT_OK, 'Object up-to-date.', $file );
					}
				} catch ( \Aws\Exception\AwsException $ex ) {
					if ( $ex->getAwsErrorCode() == 'NotFound' ) {
					} else {
						throw $ex;
					}
				}
			}

			$headers = $this->get_headers_for_file( $file );
			$headers['Content-Encoding'] = 'gzip';

			$result = $this->_put_object( array(
					'Key' => $remote_path,
					'Body' => $data
				), $headers
			);

			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_OK, 'OK', $file );
		} catch ( \Exception $ex ) {
			$error = sprintf( 'Unable to put object (%s).', $ex->getMessage() );

			return $this->_get_result( $local_path, $remote_path,
				W3TC_CDN_RESULT_ERROR, $error, $file );
		}
	}

	/**
	 * Wrapper to set headers well
	 */
	private function _put_object( $data, $headers ) {
		$data['ACL'] = 'public-read';
		$data['Bucket'] = $this->_config['bucket'];

		$data['ContentType'] = $headers['Content-Type'];

		if ( isset( $headers['Content-Encoding'] ) ) {
			$data['ContentEncoding'] = $headers['Content-Encoding'];
		}
		if ( isset( $headers['Cache-Control'] ) ) {
			$data['CacheControl'] = $headers['Cache-Control'];
		}

		return $this->api->putObject( $data );
	}

	/**
	 * Deletes files from S3
	 *
	 * @param array   $files
	 * @param array   $results
	 * @return boolean
	 */
	public function delete( $files, &$results ) {
		$error = null;

		try {
			$this->_init();
		} catch ( \Exception $ex ) {
			$results = $this->_get_results( $files, W3TC_CDN_RESULT_HALT, $ex->getMessage() );
			return false;
		}

		foreach ( $files as $file ) {
			$local_path = $file['local_path'];
			$remote_path = $file['remote_path'];

			try {
				$this->api->deleteObject( array(
						'Bucket' => $this->_config['bucket'],
						'Key' => $remote_path
					) );
				$results[] = $this->_get_result( $local_path, $remote_path,
					W3TC_CDN_RESULT_OK, 'OK', $file );
			} catch ( \Exception $ex ) {
				$results[] = $this->_get_result( $local_path, $remote_path,
					W3TC_CDN_RESULT_ERROR,
					sprintf( 'Unable to delete object (%s).',
						$ex->getMessage() ),
					$file );
			}

			if ( $this->_config['compression'] ) {
				$remote_path_gzip = $remote_path . $this->_gzip_extension;

				try {
					$this->api->deleteObject( array(
							'Bucket' => $this->_config['bucket'],
							'Key' => $remote_path_gzip
						) );
					$results[] = $this->_get_result( $local_path, $remote_path_gzip,
						W3TC_CDN_RESULT_OK, 'OK', $file );
				} catch ( \Exception $ex ) {
					$results[] = $this->_get_result( $local_path, $remote_path_gzip,
						W3TC_CDN_RESULT_ERROR,
						sprintf( 'Unable to delete object (%s).',
							$ex->getMessage() ),
						$file );
				}
			}
		}

		return !$this->_is_error( $results );
	}

	/**
	 * Test CDN connectivity works
	 */
	public function test( &$error ) {
		if ( !parent::test( $error ) ) {
			return false;
		}

		$key = 'test_s3_' . md5( time() );

		$this->_init();
		$buckets = $this->api->listBuckets();

		$bucket_found = false;
		foreach ( $buckets['Buckets'] as $bucket ) {
			if ( $bucket['Name'] == $this->_config['bucket'] ) {
				$bucket_found = true;
			}
		}

		if ( !$bucket_found ) {
			throw new \Exception( 'Bucket doesn\'t exist: %s.', $this->_config['bucket'] );
		}

		$result = $this->api->putObject( array(
				'ACL' => 'public-read',
				'Bucket' => $this->_config['bucket'],
				'Key' => $key,
				'Body' => $key
			)
		);

		$object = $this->api->getObject( array(
				'Bucket' => $this->_config['bucket'],
				'Key' => $key
			) );

		if ( $object['Body'] != $key ) {
			$error = 'Objects are not equal.';

			$this->api->deleteObject( array(
					'Bucket' => $this->_config['bucket'],
					'Key' => $key
				) );

			return false;
		}

		$this->api->deleteObject( array(
				'Bucket' => $this->_config['bucket'],
				'Key' => $key
			) );

		return true;
	}

	/**
	 * Returns CDN domain
	 *
	 * @return array
	 */
	public function get_domains() {
		if ( !empty( $this->_config['cname'] ) ) {
			return (array) $this->_config['cname'];
		} elseif ( !empty( $this->_config['bucket'] ) ) {
			$domain = sprintf( '%s.s3.amazonaws.com', $this->_config['bucket'] );

			return array(
				$domain
			);
		}

		return array();
	}

	/**
	 * Returns via string
	 *
	 * @return string
	 */
	public function get_via() {
		return sprintf( 'Amazon Web Services: S3: %s', parent::get_via() );
	}

	/**
	 * Creates bucket
	 */
	public function create_container() {
		$this->_init();

		try {
			$buckets = $this->api->listBuckets();
		} catch ( \Exception $ex ) {
			throw new \Exception( 'Unable to list buckets: ' . $ex->getMessage() );
		}

		foreach ( $buckets['Buckets'] as $bucket ) {
			if ( $bucket['Name'] == $this->_config['bucket'] ) {
				throw new \Exception( 'Bucket already exists: ' . $this->_config['bucket'] );
			}
		}

		try {
			$this->api->createBucket( array(
				'Bucket' => $this->_config['bucket'],
			) );

			$this->api->putBucketCors( array(
				'Bucket' => $this->_config['bucket'],
				'CORSConfiguration' => array(
					'CORSRules' => array(
						array(
							'AllowedHeaders' => array( '*' ),
							'AllowedMethods' => array( 'GET' ),
							'AllowedOrigins' => array( '*' )
						)
					)
				)
			) );
		} catch ( \Exception $e) {
			throw new \Exception( 'Failed to create bucket: ' . $ex->getMessage() );
		}
	}

	/**
	 * How and if headers should be set
	 *
	 * @return string W3TC_CDN_HEADER_NONE, W3TC_CDN_HEADER_UPLOADABLE, W3TC_CDN_HEADER_MIRRORING
	 */
	public function headers_support() {
		return W3TC_CDN_HEADER_UPLOADABLE;
	}
}
