<?php
/**
 * Handles the transmutation of a file.
 *
 * @package Ealab\Deployer
 */

namespace EaLab\WP_Deployer\vendor\Ealab\Deployer\Tracking\ItemValueHandler\ValueProcessor\TransmutationHandler;

use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\IgnoredTransmutationException;
use EaLab\WP_Deployer\vendor\Ealab\Deployer\Exception\WpDeployException;

/**
 * Handles the transmutation of a file.
 */
class FileTransmutationHandler extends TransmutationHandlerBase {
	/**
	 * {@inheritDoc}
	 */
	public static function getId(): string {
		return 'file';
	}

	/**
	 * {@inheritDoc}
	 */
	static public function getPublicName(): string {
		return _dpgettext( 'deployer-core', 'Transmutation handler public name', 'File' );
	}

	/**
	 * Packs the attachment file into the value.
	 *
	 * @param string     $value The value to process.
	 * @param array|null $options The options to use for the transmutation.
	 *     - "regex_iterator_pattern" (string): The regex pattern to use for the iterator.
	 *
	 * @return string JSON value with the attachment file packed.
	 *
	 * @throws IgnoredTransmutationException Could not pack the attachment file.
	 */
	public static function packAttachmentFile( string $value, ?array $options = null ): string {
		if ( empty( $value ) ) {
			throw new IgnoredTransmutationException( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: The value does not need transmutation.' ) );
		}

		$upload_dir = wp_get_upload_dir();

		if ( ! empty( $upload_dir['error'] ) ) {
			// This should never happen.
			throw new IgnoredTransmutationException( $upload_dir['error'] );
		}

		// Something like: "/var/www/html/wp-content/uploads".
		$uploads_dir_base_path = $upload_dir['basedir'];

		// Something like: "https://example.com/wp-content/uploads".
		$uploads_dir_base_url = $upload_dir['baseurl'];

		// Something like: "/var/www/html/".
		$abspath = ABSPATH;

		// Something like: "wp-content/uploads/".
		$uploads_dir_relative_path = substr( $uploads_dir_base_path, strlen( $abspath ) ) . DIRECTORY_SEPARATOR;

		// This is used to determine if the value is a relative path starting with a slash.
		// This is needed to keep compatibility with plugins that store relative paths
		// with and without the leading slash.
		$get_original_is_relative_path_starting_with_slash = function ( string $value ) {
			// Detect if the relative path starts with a backward/forward slash.
			$first_char = substr( $value, 0, 1 );

			return '/' === $first_char || DIRECTORY_SEPARATOR === $first_char;
		};

		if ( substr( $value, 0, strlen( $uploads_dir_base_url ) + 1 ) === $uploads_dir_base_url . '/' ) {
			// Dev note: this works only for files under the "uploads" dir, by design.
			$original_value_type = 'url';

			// Extract the relative file "path" from the value (which is a URL).
			$relative_file_path = $uploads_dir_relative_path . substr( $value, strlen( $uploads_dir_base_url ) + 1 );
		} elseif ( substr( $value, 0, strlen( 'http://' ) ) === 'http://' || substr( $value, 0, strlen( 'https://' ) ) === 'https://' ) {
			// Do not support unknown or external URLs.
			throw new IgnoredTransmutationException(
				sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: Unknown or external URLs are not supported: %s' ), $value )
			);
		} elseif ( substr( $value, 0, strlen( $abspath ) ) === $abspath ) {
			// This is an absolute path starting with the ABSPATH.
			$original_value_type = 'absolute_path';

			$relative_file_path = substr( $value, strlen( $abspath ) );
		} elseif ( substr( $value, 0, strlen( $uploads_dir_relative_path ) ) === $uploads_dir_relative_path
			|| ( substr( $value, 1, strlen( $uploads_dir_relative_path ) ) === $uploads_dir_relative_path && ( substr( $value, 0, 1 ) === '/' || substr( $value, 0, 1 ) === '\\' ) ) ) {
			// This is a relative path starting from the ABSPATH.
			// Supports three cases (the last one being kind of unusual):
			// - "wp-content/uploads/..."
			// - "/wp-content/uploads/..."
			// - "\wp-content\uploads\...".
			$original_value_type = 'relative_path_from_abspath';

			$original_is_relative_path_starting_with_slash = $get_original_is_relative_path_starting_with_slash( $value );

			$relative_file_path = $original_is_relative_path_starting_with_slash
				? substr( $value, 1 )
				: $value;
		} else {
			// This is **maybe** a relative path starting from the upload dir base path.
			// Supports three cases:
			// - "..."
			// - "/..."
			// - "\...".
			$original_value_type = 'relative_path_from_uploads_dir';

			$original_is_relative_path_starting_with_slash = $get_original_is_relative_path_starting_with_slash( $value );

			// Prepend the uploads dir relative path.
			$relative_file_path = $uploads_dir_relative_path . ltrim( ( $original_is_relative_path_starting_with_slash ? substr( $value, 1 ) : $value ), '/' . DIRECTORY_SEPARATOR );
		}

		if ( empty( $relative_file_path ) ) {
			// Unknown path format.
			throw new IgnoredTransmutationException( 'Unknown file path format' );
		}

		if ( DIRECTORY_SEPARATOR !== '/' ) {
			// Windows compatibility.
			// Dev note: this could be useless. To test on a real Windows environment.
			$relative_file_path = str_replace( '/', DIRECTORY_SEPARATOR, $relative_file_path );
		}

		// Load the file.
		$absolute_file_path_for_resource_read = rtrim( $abspath, '/' . DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR . $relative_file_path;

		$is_file = is_file( $absolute_file_path_for_resource_read );

		// Only allow directories under the uploads directory for security reasons.
		// This could be made configurable in the future.
		$is_allowed_dir = is_dir( $absolute_file_path_for_resource_read )
			&& ( substr( $absolute_file_path_for_resource_read, 0, strlen( $uploads_dir_base_path ) ) === $uploads_dir_base_path );

		if ( ! $is_file && ! $is_allowed_dir ) {
			// The file or directory does not exist.
			// Ignore this case, as the file may have been deleted.
			throw new IgnoredTransmutationException(
				sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: File not found for path: %s' ), $absolute_file_path_for_resource_read )
			);
		}

		if ( $is_file ) {
			$resource = fopen( $absolute_file_path_for_resource_read, 'r' );

			if ( false === $resource ) {
				// The file could not be opened.
				// A permission issue may have occurred, but let's not fail the export.
				throw new IgnoredTransmutationException(
					sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: File not readable for path: %s' ), $absolute_file_path_for_resource_read )
				);
			}

			$content = fread( $resource, filesize( $absolute_file_path_for_resource_read ) );
			fclose( $resource );
		} else {
			// This is a directory.
			// Zip the directory and encode it.
			$zip_file_resource = tmpfile();
			$zip_file_path     = stream_get_meta_data( $zip_file_resource )['uri'];

			if ( empty( $zip_file_path ) ) {
				throw new IgnoredTransmutationException(
					sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: Could not create temporary file for path: %s' ), $absolute_file_path_for_resource_read )
				);
			}

			$zip = new \ZipArchive();

			if ( ! $zip->open( $zip_file_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ) ) {
				throw new IgnoredTransmutationException(
					sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: Could not open temporary file for path: %s' ), $absolute_file_path_for_resource_read )
				);
			}

			$files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $absolute_file_path_for_resource_read ) );

			$regex_iterator_pattern = $options['regex_iterator_pattern'] ?? null;

			if ( null !== $regex_iterator_pattern ) {
				// TODO: to test when the UI will allow it.
				$files = new \RegexIterator( $files, $regex_iterator_pattern );
			}

			/** @var \SplFileInfo $file */
			foreach ( $files as $file ) {
				if ( ! $file->isFile() ) {
					continue;
				}

				$file_relative_path = str_replace( $absolute_file_path_for_resource_read, '', $file->getRealPath() );

				// Relative path from the site root.
				$zip->addFile( $file->getRealPath(), $file_relative_path );
			}

			// Close the zip archive.
			$zip->close();

			$content = file_get_contents( $zip_file_path );
		}

		$to_encode = array(
			'is_directory'                                  => ! $is_file,
			'original_is_relative_path_starting_with_slash' => $original_is_relative_path_starting_with_slash ?? null,
			'original_value_type'                           => $original_value_type,
			// Encode to base64 to avoid issues with special characters.
			'payload'                                       => base64_encode( $content ),
			// Enforce the use of forward slashes; this will make the diffs work on any OS.
			'normalized_relative_path'                      => str_replace( DIRECTORY_SEPARATOR, '/', $relative_file_path ),
		);

		// Pack the file into the value.
		return json_encode( $to_encode );
	}

	/**
	 * {@inheritDoc}
	 */
	public function toExport( $value, ?array $options = array() ) {
		if ( ! is_string( $value ) ) {
			// This is not a file path. Leave this attribute value as is.
			throw new IgnoredTransmutationException();
		}

		$encoded_file_data = static::packAttachmentFile( $value );

		// Finally encapsulate the attribute value in a placeholder.
		return static::getProcessorPlaceholder( $encoded_file_data );
	}

	/**
	 * {@inheritdoc}
	 */
	public function toImportFinalize( $value, ?array $options = array() ) {
		return static::applyOnPlaceholders(
			$value,
			function ( array $regex_match ) {
				$to_decode = $regex_match[3];

				return static::unpackAttachmentFile( $to_decode );
			}
		);
	}

	/**
	 * {@inheritdoc}
	 */
	public function toImportPresave( $value, ?array $options = array() ) {
		return static::applyOnPlaceholders(
			$value,
			function ( array $regex_match ) {
				// Just empty the value for presave to prevent saving the file twice.
				return '';
			}
		);
	}

	/**
	 * Unpacks the attachment file from the value and reverses the value to the path / URL for this instance.
	 *
	 * @param string $value The JSON value to process.
	 *
	 * @return string The value with the attachment file unpacked.
	 *
	 * @throws IgnoredTransmutationException|WpDeployException Could not unpack the attachment file.
	 */
	public static function unpackAttachmentFile( string $value ): string {
		// Attempt to decode the value.
		$decoded = json_decode( $value, true );

		if ( json_last_error() !== JSON_ERROR_NONE ) {
			// The value is not a valid JSON string.
			// This could mean that this processor failed to pack the file on export.
			return $value;
		}

		$is_directory                                  = $decoded['is_directory'];
		$original_is_relative_path_starting_with_slash = $decoded['original_is_relative_path_starting_with_slash'] ?? null;
		$original_value_type                           = $decoded['original_value_type'];
		$payload                                       = $decoded['payload'];
		$normalized_relative_path                      = $decoded['normalized_relative_path'];

		// Attempt to decode the file.
		$decoded = base64_decode( $payload, true );

		if ( false === $decoded ) {
			// The file is not base64 encoded.
			// This should never happen.
			throw new WpDeployException( sprintf( _dgettext( 'deployer-core', 'The attachment file is not properly prepared for export/import. Relative path is: %s' ), $normalized_relative_path ) );
		}

		// We have a valid base64 encoded file.
		// Now, we need to write it.
		$upload_dir = wp_get_upload_dir();

		if ( ! empty( $upload_dir['error'] ) ) {
			// This should never happen.
			throw new WpDeployException( $upload_dir['error'] );
		}

		// Something like: "/var/www/html/wp-content/uploads".
		$uploads_dir_base_path = $upload_dir['basedir'];

		// Something like: "https://example.com/wp-content/uploads".
		$uploads_dir_base_url = $upload_dir['baseurl'];

		// Something like: "/var/www/html/".
		$abspath = ABSPATH;

		// Something like: "wp-content/uploads/".
		$uploads_dir_relative_path = substr( $uploads_dir_base_path, strlen( $abspath ) ) . DIRECTORY_SEPARATOR;

		// This will be used to create the file; not for saving in the database.
		$relative_path_for_resource = $normalized_relative_path;

		if ( DIRECTORY_SEPARATOR !== '/' ) {
			// Windows compatibility.
			// Dev note: this could be useless. Test it on a real Windows environment.
			$relative_path_for_resource = str_replace( '/', DIRECTORY_SEPARATOR, $normalized_relative_path );
		}

		$absolute_file_path_for_resource = $abspath . $relative_path_for_resource;

		// Create the directory if it does not exist.
		$directory = $is_directory ? $absolute_file_path_for_resource : dirname( $absolute_file_path_for_resource );

		if ( ! is_dir( $directory ) ) {
			// Create the directory.
			// WP implementation of wp_mkdir_p() is used to ensure that the directory is created recursively
			// and that the permissions are set correctly.
			$created = wp_mkdir_p( $directory );

			if ( false === $created ) {
				// The directory could not be created.
				// Usually a permission issue.
				throw new WpDeployException( sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: Cannot create dir for: %s' ), $absolute_file_path_for_resource ) );
			}
		}

		if ( ! $is_directory ) {
			$resource = fopen( $absolute_file_path_for_resource, 'w' );

			if ( false === $resource ) {
				// The file could not be opened.
				// Usually a permission issue.
				throw new WpDeployException( sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: Cannot write file for: %s' ), $absolute_file_path_for_resource ) );
			}

			fwrite( $resource, $decoded );
			fclose( $resource );
		} else {
			// The file is a zip archive that contains a directory.
			// Write the decoded content to a temporary file.
			$zip_file_resource = tmpfile();

			if ( false === $zip_file_resource ) {
				// The temporary file could not be created.
				// Usually a permission issue.
				throw new WpDeployException( sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: Cannot create temporary file for: %s' ), $absolute_file_path_for_resource ) );
			}

			fwrite( $zip_file_resource, $decoded );

			// Extract the zip archive to the directory.
			$zip = new \ZipArchive();

			if ( true !== $zip->open( stream_get_meta_data( $zip_file_resource )['uri'] ) ) {
				throw new WpDeployException( sprintf( _dpgettext( 'deployer-core', 'Transmutation handler', 'FileTransmutationHandler: Cannot open zip file for: %s' ), $absolute_file_path_for_resource ) );
			}

			$zip->extractTo( $directory );
			$zip->close();
		}

		// Return the value that should be saved.
		switch ( $original_value_type ) {
			case 'url':
				// Remove the uploads part from the path;
				// it's already contained in $uploads_dir_base_url.
				$relative_path_from_uploads_dir = substr( $normalized_relative_path, strlen( $uploads_dir_relative_path ) );

				return $uploads_dir_base_url . '/' . $relative_path_from_uploads_dir;

			case 'absolute_path':
				return $absolute_file_path_for_resource;

			case 'relative_path_from_abspath':
				$maybe_slash = $original_is_relative_path_starting_with_slash ? DIRECTORY_SEPARATOR : '';

				return $maybe_slash . $normalized_relative_path;

			case 'relative_path_from_uploads_dir':
				$maybe_slash = $original_is_relative_path_starting_with_slash ? DIRECTORY_SEPARATOR : '';

				// Remove the uploads part from the path.
				$relative_path_from_uploads_dir = substr( $normalized_relative_path, strlen( $uploads_dir_relative_path ) );

				return $maybe_slash . $relative_path_from_uploads_dir;

			default:
				// Theoretically impossible case. Just for the IDE/linters to stop complaining.
				return '';
		}
	}
}
