<?php
namespace WP_Rocket\Addon\Busting;

use WP_Rocket\Logger\Logger;

trait FileBustingTrait {
	/**
	 * Saves the content of the URL to bust to the busting file if it doesn't exist yet.
	 *
	 * @since  3.2.4
	 * @access public
	 *
	 * @param  string $url URL to get the content from.
	 * @return bool
	 */
	public function save( $url ) {
		if ( $this->get_busting_version() ) {
			// We have a local copy.
			Logger::debug(
				'Found local file.',
				[
					self::LOGGER_CONTEXT,
					'path' => $this->get_busting_path(),
				]
			);
			return true;
		}

		if ( $this->refresh_save( $url ) ) {
			// We downloaded a fresh copy.
			Logger::debug(
				'New copy downloaded.',
				[
					self::LOGGER_CONTEXT,
					'path' => $this->get_busting_path(),
				]
			);
			return true;
		}

		return false;
	}

	/**
	 * Deletes the busting file.
	 *
	 * @since  3.1
	 * @since  3.2.4 Handle versioning.
	 * @access public
	 * @author Remy Perona
	 * @author Grégory Viguier
	 *
	 * @return bool True on success. False on failure.
	 */
	public function delete() {
		$files = $this->get_all_files();

		if ( false === $files ) {
			// Error.
			return false;
		}

		$this->file_version = null;

		if ( ! $files ) {
			// No local files yet.
			return true;
		}

		return $this->delete_files( array_keys( $files ) );
	}

	/** ----------------------------------------------------------------------------------------- */
	/** LOCAL FILE ============================================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get the version of the current busting file.
	 *
	 * @since  3.2.4
	 * @access protected
	 * @author Grégory Viguier
	 *
	 * @return string|bool Version of the file. False if the file does not exist.
	 */
	protected function get_busting_version() {
		if ( ! empty( $this->file_version ) ) {
			return $this->file_version;
		}

		$files = $this->get_all_files();

		if ( ! $files ) {
			// Error or no local files yet.
			return false;
		}

		// Since we're not supposed to have several files, return the first one.
		$this->file_version = reset( $files );

		return $this->file_version;
	}

	/**
	 * Get all cached files in the directory.
	 * In a perfect world, there should be only one.
	 *
	 * @since  3.2.4
	 * @access private
	 *
	 * @return bool|array A list of file names (as array keys) and versions (as array values). False on failure.
	 */
	private function get_all_files() {
		$dir_path = rtrim( $this->busting_path, '\\/' );

		if ( ! $this->filesystem->exists( $dir_path ) ) {
			return [];
		}

		if ( ! $this->filesystem->is_readable( $dir_path ) ) {
			Logger::error(
				'Directory is not readable.',
				[
					self::LOGGER_CONTEXT,
					'path' => $dir_path,
				]
			);
			return false;
		}

		$pattern = '/' . sprintf(
			$this->escape_file_name( $this->filename_pattern ),
			'([a-f0-9]{32}|local)'
		) . '/';

		$entries = _rocket_get_dir_files_by_regex( $dir_path, $pattern );

		$list = [];
		foreach ( $entries as $entry ) {
			$filename = $entry->getFilename();

			preg_match( $pattern, $filename, $file_details_match );
			if ( ! empty( $file_details_match[1] ) ) {
				$list[ $filename ] = $file_details_match[1];
			}
		}

		return $list;
	}

	/**
	 * Get the final URL for the current cache busting file.
	 *
	 * @since  3.2.4
	 * @access protected
	 *
	 * @return string|bool URL of the file. False if the file does not exist.
	 */
	public function get_busting_url() {
		return $this->get_busting_file_url( $this->get_busting_version() );
	}

	/**
	 * Get the path to the current cache busting file.
	 *
	 * @since  3.2.4
	 * @access protected
	 * @author Grégory Viguier
	 *
	 * @return string|bool URL of the file. False if the file does not exist.
	 */
	protected function get_busting_path() {
		return $this->get_busting_file_path( $this->get_busting_version() );
	}

	/**
	 * Get the final URL for a cache busting file.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $version The file version.
	 * @return string|bool     URL of the file with this version. False if no versions are provided.
	 */
	private function get_busting_file_url( $version ) {
		if ( ! $version ) {
			return false;
		}

		$filename = $this->get_busting_file_name( $version );

		// This filter is documented in inc/functions/minify.php.
		return apply_filters( 'rocket_js_url', $this->busting_url . $filename );
	}

	/**
	 * Get the local file name.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $version The file version.
	 * @return string|bool     The name of the file with this version. False if no versions are provided.
	 */
	private function get_busting_file_name( $version ) {
		if ( ! $version ) {
			return false;
		}

		return sprintf( $this->filename_pattern, $version );
	}

	/**
	 * Get the local file path.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $version The file version.
	 * @return string|bool     Path to the file with this version. False if no versions are provided.
	 */
	private function get_busting_file_path( $version ) {
		if ( ! $version ) {
			return false;
		}

		return $this->busting_path . $this->get_busting_file_name( $version );
	}

	/**
	 * Escape a file name, to be used in a regex pattern (delimiter is `/`).
	 * `%s` conversion specifications are protected.
	 *
	 * @since  3.2.4
	 * @access private
	 *
	 * @param  string $filename_pattern The file name.
	 * @return string
	 */
	private function escape_file_name( $filename_pattern ) {
		return preg_quote( $filename_pattern, '/' );
	}

	/**
	 * Delete busting files.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  array $files A list of file names.
	 * @return bool         True if files have been deleted (or no files have been provided). False on failure.
	 */
	private function delete_files( $files ) {
		if ( ! $files ) {
			// ¯\_(ツ)_/¯
			return true;
		}

		$has_deleted = false;
		$error_paths = [];

		foreach ( $files as $file_name ) {
			if ( ! $this->filesystem->delete( $this->busting_path . $file_name, false, 'f' ) ) {
				$error_paths[] = $this->busting_path . $file_name;
			} else {
				$has_deleted = true;
			}
		}

		if ( $error_paths ) {
			// Group all deletion errors into one log.
			Logger::error(
				'Local file(s) could not be deleted.',
				[
					self::LOGGER_CONTEXT,
					'paths' => $error_paths,
				]
			);
		}

		return $has_deleted;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** UPDATE THE LOCAL FILE =================================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Add new contents to a file. If the file doesn't exist, it is created.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $file_path     Path to the file to update.
	 * @param  string $file_contents New contents.
	 * @return string|bool           The file contents on success. False on failure.
	 */
	private function update_file_contents( $file_path, $file_contents ) {
		if ( ! $this->is_busting_dir_writable() ) {
			return false;
		}

		if ( ! rocket_put_content( $file_path, $file_contents ) ) {
			Logger::error(
				'Contents could not be written into file.',
				[
					self::LOGGER_CONTEXT,
					'path' => $file_path,
				]
			);
			return false;
		}

		return $file_contents;
	}

	/**
	 * Tell if the directory containing the busting file is writable.
	 *
	 * @since  3.2
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @return bool
	 */
	private function is_busting_dir_writable() {
		if ( ! $this->filesystem->exists( $this->busting_path ) ) {
			rocket_mkdir_p( $this->busting_path );
		}

		if ( ! $this->filesystem->is_writable( $this->busting_path ) ) {
			Logger::error(
				'Directory is not writable.',
				[
					self::LOGGER_CONTEXT,
					'paths' => $this->busting_path,
				]
			);
			return false;
		}

		return true;
	}

	/** ----------------------------------------------------------------------------------------- */
	/** GET LOCAL/REMOTE CONTENTS =============================================================== */
	/** ----------------------------------------------------------------------------------------- */

	/**
	 * Get a file contents. If the file doesn't exist, new contents are fetched remotely.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $file_path Path to the file.
	 * @param  string $file_url  URL to the remote file.
	 * @return string|bool       The contents on success, false on failure.
	 */
	private function get_file_or_remote_contents( $file_path, $file_url ) {
		$content = $this->get_file_contents( $file_path );

		if ( $content ) {
			// We have a local file.
			return $content;
		}

		return $this->get_remote_contents( $file_url );
	}

	/**
	 * Get a file contents.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $file_path Path to the file.
	 * @return string|bool       The contents on success, false on failure.
	 */
	private function get_file_contents( $file_path ) {
		if ( ! $this->filesystem->exists( $file_path ) ) {
			Logger::error(
				'Local file does not exist.',
				[
					self::LOGGER_CONTEXT,
					'path' => $file_path,
				]
			);
			return false;
		}

		if ( ! $this->filesystem->is_readable( $file_path ) ) {
			Logger::error(
				'Local file is not readable.',
				[
					self::LOGGER_CONTEXT,
					'path' => $file_path,
				]
			);
			return false;
		}

		$content = $this->filesystem->get_contents( $file_path );

		if ( ! $content ) {
			Logger::error(
				'Local file is empty.',
				[
					self::LOGGER_CONTEXT,
					'path' => $file_path,
				]
			);
			return false;
		}

		return $content;
	}

	/**
	 * Get the contents of a URL.
	 *
	 * @since  3.2.4
	 * @access private
	 * @author Grégory Viguier
	 *
	 * @param  string $url The URL to request.
	 * @return string|bool The contents on success. False on failure.
	 */
	private function get_remote_contents( $url ) {
		try {
			$response = wp_remote_get( $url );
		} catch ( Exception $e ) {
			Logger::error(
				'Remote file could not be fetched.',
				[
					self::LOGGER_CONTEXT,
					'url'      => $url,
					'response' => $e->getMessage(),
				]
			);
			return false;
		}

		if ( is_wp_error( $response ) ) {
			Logger::error(
				'Remote file could not be fetched.',
				[
					self::LOGGER_CONTEXT,
					'url'      => $url,
					'response' => $response->get_error_message(),
				]
			);
			return false;
		}

		$contents = wp_remote_retrieve_body( $response );

		if ( ! $contents ) {
			Logger::error(
				'Remote file could not be fetched.',
				[
					self::LOGGER_CONTEXT,
					'url'      => $url,
					'response' => $response,
				]
			);
			return false;
		}

		return $contents;
	}
}
