<?php

namespace DeliciousBrains\WP_Offload_Media\Items;

use AS3CF_Error;
use AS3CF_Utils;
use DeliciousBrains\WP_Offload_Media\Providers\Storage\Storage_Provider;
use Exception;
use WP_Error;

class Upload_Handler extends Item_Handler {
	/**
	 * @var string
	 */
	protected static $item_handler_key = 'upload';

	/**
	 * Keep track of individual files we've already attempted to upload
	 *
	 * @var array
	 */
	protected $attempted_upload = array();

	/**
	 * The default options that should be used if none supplied.
	 *
	 * @return array
	 */
	public static function default_options() {
		return array(
			'offloaded_files' => array(),
		);
	}

	/**
	 * Prepare item for uploading by running filters, updating
	 *
	 * @param Item  $as3cf_item
	 * @param array $options
	 *
	 * @return Manifest|WP_Error
	 */
	protected function pre_handle( Item $as3cf_item, array $options ) {
		$manifest         = new Manifest();
		$source_type_name = $this->as3cf->get_source_type_name( $as3cf_item->source_type() );
		$primary_key      = Item::primary_object_key();

		// Check for valid file path before attempting upload
		if ( empty( $as3cf_item->source_path() ) ) {
			$error_msg = sprintf(
			/* translators: %1$s is a media source, e.g. "Media Library", %2$d is its unique ID. */
				__( '%1$s with id %2$d does not have a valid file path', 'amazon-s3-and-cloudfront' ),
				$source_type_name,
				$as3cf_item->source_id()
			);

			return $this->return_handler_error( $error_msg );
		}

		// Ensure path is a string
		if ( ! is_string( $as3cf_item->source_path() ) ) {
			$error_msg = sprintf(
			/* translators: %1$s is a media source, e.g. "Media Library", %2$d is its unique ID. */
				__( '%1$s with id %2$d. Provided path is not a string', 'amazon-s3-and-cloudfront' ),
				$source_type_name,
				$as3cf_item->source_id()
			);

			return $this->return_handler_error( $error_msg );
		}

		// Ensure primary source file exists for new offload.
		if ( empty( $as3cf_item->id() ) && ! file_exists( $as3cf_item->full_source_path( $primary_key ) ) ) {
			$error_msg = sprintf(
			/* translators: %1$s is file path, %2$s is a media source, e.g. "Media Library", %3$d is its unique ID. */
				__( 'Primary file %1$s for %2$s with id %3$d does not exist', 'amazon-s3-and-cloudfront' ),
				$as3cf_item->full_source_path( $primary_key ),
				$source_type_name,
				$as3cf_item->source_id()
			);

			return $this->return_handler_error( $error_msg );
		}

		// Get primary file's stats.
		$file_name     = wp_basename( $as3cf_item->source_path() );
		$file_type     = wp_check_filetype_and_ext( $as3cf_item->full_source_path(), $file_name );
		$allowed_types = $this->as3cf->get_allowed_mime_types();

		// check mime type of file is in allowed provider mime types
		if ( ! in_array( $file_type['type'], $allowed_types, true ) ) {
			$error_msg = sprintf(
			/* translators: %1$s is mime type, %2$s is a media source, e.g. "Media Library", %3$d is its unique ID. */
				__( 'Mime type "%1$s" is not allowed (%2$s with id %3$d)', 'amazon-s3-and-cloudfront' ),
				$file_type['type'],
				$source_type_name,
				$as3cf_item->source_id()
			);

			return $this->return_handler_error( $error_msg );
		}

		$default_acl = $this->as3cf->get_storage_provider()->get_default_acl();
		$private_acl = $this->as3cf->get_storage_provider()->get_private_acl();

		foreach ( $as3cf_item->objects() as $object_key => $object ) {
			// Avoid attempting uploading to an item that doesn't have the primary file in place.
			if ( $primary_key !== $object_key && empty( $as3cf_item->id() ) && ! isset( $manifest->objects[ $primary_key ] ) ) {
				continue;
			}

			$source_path = $as3cf_item->full_source_path( $object_key );

			// If the file has already been offloaded,
			// don't try and (fail to) re-offload if the file isn't available.
			if ( $this->in_offloaded_files( $object['source_file'], $options ) && ! file_exists( $source_path ) ) {
				continue;
			}

			/**
			 * This filter allows you to change the public/private status of an individual file associated
			 * with an uploaded item before it's uploaded to the provider.
			 *
			 * @param bool   $is_private Should the object be private?
			 * @param string $object_key A unique file identifier for a composite item, e.g. image's "size" such as full, small, medium, large.
			 * @param Item   $as3cf_item The item being uploaded.
			 *
			 * @return bool
			 */
			$is_private = apply_filters(
				'as3cf_upload_object_key_as_private',
				$as3cf_item->is_private( $object_key ),
				$object_key,
				$as3cf_item
			);
			$as3cf_item->set_is_private( $is_private, $object_key );

			$object_acl = $as3cf_item->is_private( $object_key ) ? $private_acl : $default_acl;

			$args = array(
				'Bucket'       => $as3cf_item->bucket(),
				'Key'          => $as3cf_item->path( $object_key ),
				'SourceFile'   => $source_path,
				'ContentType'  => AS3CF_Utils::get_mime_type( $object['source_file'] ),
				'CacheControl' => 'max-age=31536000',
			);

			// Only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
			if (
				! empty( $object_acl ) &&
				$this->as3cf->use_acl_for_intermediate_size( 0, $object_key, $as3cf_item->bucket(), $as3cf_item )
			) {
				$args['ACL'] = $object_acl;
			}

			// TODO: Remove GZIP functionality.
			// Handle gzip on supported items
			if (
				$this->should_gzip_file( $source_path, $as3cf_item->source_type() ) &&
				false !== ( $gzip_body = gzencode( file_get_contents( $source_path ) ) )
			) {
				unset( $args['SourceFile'] );

				$args['Body']            = $gzip_body;
				$args['ContentEncoding'] = 'gzip';
			}

			$args = Storage_Provider::filter_object_meta( $args, $as3cf_item, $object_key );

			// If the bucket is changed by the filter while processing the primary object,
			// we should try and use that bucket for the item.
			// If the bucket name is invalid, revert to configured bucket but log it.
			// We don't abort here as ephemeral filesystems need to be accounted for,
			// and the configured bucket is at least known to work.
			if ( $primary_key === $object_key && $as3cf_item->bucket() !== $args['Bucket'] && empty( $as3cf_item->id() ) ) {
				$bucket = $this->as3cf->check_bucket( $args['Bucket'] );

				if ( $bucket ) {
					$region = $this->as3cf->get_bucket_region( $bucket );

					if ( is_wp_error( $region ) ) {
						unset( $region );
					}
				}

				if ( empty( $bucket ) || empty( $region ) ) {
					$mesg = sprintf(
					/* translators: %1$s is bucket name, %2$s is a different bucket name. */
						__( 'Bucket name "%1$s" is invalid, using "%2$s" instead.', 'amazon-s3-and-cloudfront' ),
						$args['Bucket'],
						$as3cf_item->bucket()
					);
					AS3CF_Error::log( $mesg );
					$args['Bucket'] = $as3cf_item->bucket();
				} else {
					$args['Bucket'] = $bucket;
					$as3cf_item->set_bucket( $bucket );
					$as3cf_item->set_region( $region );
				}

				unset( $bucket, $region );
			} elseif ( $primary_key === $object_key && $as3cf_item->bucket() !== $args['Bucket'] && ! empty( $as3cf_item->id() ) ) {
				$args['Bucket'] = $as3cf_item->bucket();
				AS3CF_Error::log(
					__(
						'The bucket may not be changed via filters for a previously offloaded item.',
						'amazon-s3-and-cloudfront'
					)
				);
			} elseif ( $primary_key !== $object_key && $as3cf_item->bucket() !== $args['Bucket'] ) {
				$args['Bucket'] = $as3cf_item->bucket();
			}

			// If the Key has been changed for the primary object key, then that should be reflected in the item.
			if ( $primary_key === $object_key && $as3cf_item->path( $object_key ) !== $args['Key'] && empty( $as3cf_item->id() ) ) {
				$prefix = AS3CF_Utils::trailingslash_prefix( dirname( $args['Key'] ) );

				if ( $prefix === '.' ) {
					$prefix = '';
				}

				$as3cf_item->update_path_prefix( $prefix );

				// If the filter tried to use a different filename too, log it.
				if ( wp_basename( $args['Key'] ) !== wp_basename( $as3cf_item->path( $object_key ) ) ) {
					$mesg = sprintf(
					/* translators: %1$s is file path, %2$s is a different file path. */
						__(
							'The offloaded filename must not be changed, "%1$s" has been used instead of "%2$s".',
							'amazon-s3-and-cloudfront'
						),
						wp_basename( $as3cf_item->path( $object_key ) ),
						wp_basename( $args['Key'] )
					);
					AS3CF_Error::log( $mesg );
				}
			} elseif ( $primary_key === $object_key && $as3cf_item->path( $object_key ) !== $args['Key'] && ! empty( $as3cf_item->id() ) ) {
				$args['Key'] = $as3cf_item->path( $object_key );
				AS3CF_Error::log(
					__(
						'The key may not be changed via filters for a previously offloaded item.',
						'amazon-s3-and-cloudfront'
					)
				);
			} elseif ( $primary_key !== $object_key && $as3cf_item->path( $object_key ) !== $args['Key'] ) {
				$args['Key'] = $as3cf_item->path( $object_key );
			}

			// If ACL has been set, does the object's is_private need updating?
			$is_private = ! empty( $args['ACL'] ) && $private_acl === $args['ACL'] || $as3cf_item->is_private( $object_key );
			$as3cf_item->set_is_private( $is_private, $object_key );

			// Protect against filter use and only set ACL if actually required, some storage provider and bucket settings disable changing ACL.
			if (
				isset( $args['ACL'] ) &&
				! $this->as3cf->use_acl_for_intermediate_size( 0, $object_key, $as3cf_item->bucket(), $as3cf_item )
			) {
				unset( $args['ACL'] );
			}

			// Adjust the actual Key to add the private prefix before uploading.
			if ( $as3cf_item->is_private( $object_key ) ) {
				$args['Key'] = $as3cf_item->provider_key( $object_key );
			}

			// If we've already attempted to offload this source file, leave it out of the manifest.
			if ( in_array( md5( serialize( $args ) ), $this->attempted_upload ) ) {
				continue;
			}

			if ( $primary_key === $object_key ) {
				/**
				 * Actions fires when an Item's primary file might be offloaded.
				 *
				 * This action gives notice that an Item is being processed for upload to a bucket,
				 * and the given arguments represent the primary file's potential offload location.
				 * However, if the current process is for picking up extra files associated with the item,
				 * the indicated primary file may not actually be offloaded if it does not exist
				 * on the server but has already been offloaded.
				 *
				 * @param Item  $as3cf_item The Item whose files are being offloaded.
				 * @param array $args       The arguments that could be used to offload the primary file.
				 */
				do_action( 'as3cf_pre_upload_object', $as3cf_item, $args );
			}

			$manifest->objects[ $object_key ]['args'] = $args;
		}

		return $manifest;
	}

	/**
	 * Upload item files to remote storage provider
	 *
	 * @param Item     $as3cf_item
	 * @param Manifest $manifest
	 * @param array    $options
	 *
	 * @return bool|WP_Error
	 */
	protected function handle_item( Item $as3cf_item, Manifest $manifest, array $options ) {
		$source_type_name = $this->as3cf->get_source_type_name( $as3cf_item->source_type() );

		try {
			$provider_client = $this->as3cf->get_provider_client( $as3cf_item->region() );
		} catch ( Exception $e ) {
			return $this->return_handler_error( $e->getMessage() );
		}

		foreach ( $manifest->objects as $object_key => &$object ) {
			$args = $object['args'];

			$object['upload_result'] = array(
				'status'  => null,
				'message' => null,
			);

			if ( ! file_exists( $args['SourceFile'] ) ) {
				$error_msg = sprintf(
				/* translators: %1$s is a file path, %2$s is a media source, e.g. "Media Library", %3$d is its unique ID. */
					__( 'File %1$s does not exist (%2$s with id %3$d)', 'amazon-s3-and-cloudfront' ),
					$args['SourceFile'],
					$source_type_name,
					$as3cf_item->source_id()
				);

				$object['upload_result']['status']  = self::STATUS_FAILED;
				$object['upload_result']['message'] = $error_msg;

				// If the missing source file is the primary file, abort the whole process.
				if ( Item::primary_object_key() === $object_key ) {
					return false;
				}

				continue;
			}

			$this->attempted_upload[] = md5( serialize( $args ) );

			// Try to do the upload
			try {
				$provider_client->upload_object( $args );

				$object['upload_result']['status'] = self::STATUS_OK;
			} catch ( Exception $e ) {
				$error_msg = sprintf(
				/* translators: %1$s is file path, %2$s is an error message, %3$s is a media source, e.g. "Media Library", %4$d is its unique ID. */
					__( 'Error offloading %1$s to provider: %2$s (%3$s with id %4$d)', 'amazon-s3-and-cloudfront' ),
					$args['SourceFile'],
					$e->getMessage(),
					$source_type_name,
					$as3cf_item->source_id()
				);

				$object['upload_result']['status']  = self::STATUS_FAILED;
				$object['upload_result']['message'] = $error_msg;
			}
		}

		return true;
	}

	/**
	 * Handle local housekeeping after uploads.
	 *
	 * @param Item     $as3cf_item
	 * @param Manifest $manifest
	 * @param array    $options
	 *
	 * @return bool|WP_Error
	 */
	protected function post_handle( Item $as3cf_item, Manifest $manifest, array $options ) {
		$item_objects = $as3cf_item->objects();
		$errors       = new WP_Error();
		$i            = 1;

		// Reconcile the Item's objects with their manifest status.
		foreach ( $item_objects as $object_key => $object ) {
			// If there was no attempt made to offload the file,
			// then remove it from list of offloaded objects.
			// However, if the source file has previously been offloaded,
			// we should just skip any further processing of it
			// as the associated objects are still offloaded.
			if ( ! isset( $manifest->objects[ $object_key ]['upload_result']['status'] ) ) {
				if ( empty( $options['offloaded_files'][ $object['source_file'] ] ) ) {
					unset( $item_objects[ $object_key ] );
				}
				continue;
			}

			// If the upload didn't succeed, we need to remove the object/size from the item.
			// However, if the source file has previously been offloaded, we should just log the error.
			if ( $manifest->objects[ $object_key ]['upload_result']['status'] !== self::STATUS_OK ) {
				if ( empty( $options['offloaded_files'][ $object['source_file'] ] ) ) {
					unset( $item_objects[ $object_key ] );
				}
				$errors->add(
					'upload-object-' . ( $i++ ),
					$manifest->objects[ $object_key ]['upload_result']['message']
				);
			}
		}

		// Set the potentially changed list of offloaded objects.
		$as3cf_item->set_objects( $item_objects );

		// Only save if we have the primary file uploaded.
		if ( isset( $item_objects[ Item::primary_object_key() ] ) ) {
			$result = $as3cf_item->save();

			if ( is_wp_error( $result ) ) {
				$errors->merge_from( $result );
			}
		}

		/**
		 * Fires action after uploading finishes
		 *
		 * @param Item $as3cf_item The item that was just uploaded
		 */
		do_action( 'as3cf_post_upload_item', $as3cf_item );

		if ( count( $errors->get_error_codes() ) ) {
			return $errors;
		}

		return true;
	}

	/**
	 * Should gzip file
	 *
	 * @param string $file_path
	 * @param string $source_type
	 *
	 * @return bool
	 */
	protected function should_gzip_file( $file_path, $source_type ) {
		$file_type = wp_check_filetype_and_ext( $file_path, $file_path );
		$mimes     = $this->get_mime_types_to_gzip( $source_type );

		if ( in_array( $file_type, $mimes ) && is_readable( $file_path ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Get mime types to gzip
	 *
	 * @param string $source_type
	 *
	 * @return array
	 */
	protected function get_mime_types_to_gzip( $source_type ) {
		/**
		 * Return array of mime types that needs to be gzipped before upload
		 *
		 * @param array  $mime_types    The array of mime types
		 * @param bool   $media_library If the uploaded file is part of the media library
		 * @param string $source_type   The source type of the uploaded item
		 */
		return apply_filters(
			'as3cf_gzip_mime_types',
			array(
				'css'   => 'text/css',
				'eot'   => 'application/vnd.ms-fontobject',
				'html'  => 'text/html',
				'ico'   => 'image/x-icon',
				'js'    => 'application/javascript',
				'json'  => 'application/json',
				'otf'   => 'application/x-font-opentype',
				'rss'   => 'application/rss+xml',
				'svg'   => 'image/svg+xml',
				'ttf'   => 'application/x-font-ttf',
				'woff'  => 'application/font-woff',
				'woff2' => 'application/font-woff2',
				'xml'   => 'application/xml',
			),
			'media_library' === $source_type,
			$source_type
		);
	}

	/**
	 * Has the given file name already been offloaded?
	 *
	 * @param string $filename
	 * @param array  $options
	 *
	 * @return bool
	 */
	private function in_offloaded_files( $filename, $options ) {
		if ( empty( $options['offloaded_files'] ) ) {
			return false;
		}

		return array_key_exists( $filename, $options['offloaded_files'] );
	}
}
