<?php
declare( strict_types=1 );

namespace WPDesk\Init;

class DefaultHeaderParser implements HeaderParser {

	private const KB_IN_BYTES = 1024;
	private const HEADERS = [
		'Name'        => 'Plugin Name',
		'PluginURI'   => 'Plugin URI',
		'Version'     => 'Version',
		'Author'      => 'Author',
		'AuthorURI'   => 'Author URI',
		'TextDomain'  => 'Text Domain',
		'DomainPath'  => 'Domain Path',
		'Network'     => 'Network',
		'RequiresWP'  => 'Requires at least',
		'RequiresWC'  => 'WC requires at least',
		'RequiresPHP' => 'Requires PHP',
		'TestedWP'    => 'Tested up to',
		'TestedWC'    => 'WC tested up to',
		'UpdateURI'   => 'Update URI',
	];

	/**
	 * Parses the plugin contents to retrieve plugin's metadata.
	 * All plugin headers must be on their own line. Plugin description must not have
	 * any newlines, otherwise only parts of the description will be displayed.
	 * The below is formatted for printing.
	 *     /*
	 *     Plugin Name: Name of the plugin.
	 *     Plugin URI: The home page of the plugin.
	 *     Author: Plugin author's name.
	 *     Author URI: Link to the author's website.
	 *     Version: Plugin version.
	 *     Text Domain: Optional. Unique identifier, should be same as the one used in
	 *          load_plugin_textdomain().
	 *     Domain Path: Optional. Only useful if the translations are located in a
	 *          folder above the plugin's base path. For example, if .mo files are
	 *          located in the locale folder then Domain Path will be "/locale/" and
	 *          must have the first slash. Defaults to the base folder the plugin is
	 *          located in.
	 *     Network: Optional. Specify "Network: true" to require that a plugin is activated
	 *          across all sites in an installation. This will prevent a plugin from being
	 *          activated on a single site when Multisite is enabled.
	 *     Requires at least: Optional. Specify the minimum required WordPress version.
	 *     Requires PHP: Optional. Specify the minimum required PHP version.
	 *     * / # Remove the space to close comment.
	 * The first 8 KB of the file will be pulled in and if the plugin data is not
	 * within that first 8 KB, then the plugin author should correct their plugin
	 * and move the plugin data headers to the top.
	 * The plugin file is assumed to have permissions to allow for scripts to read
	 * the file. This is not checked however and the file is only opened for
	 * reading.
	 *
	 * @param string $plugin_file Absolute path to the main plugin file.
	 *
	 * @return array{
	 *     Name: string,
	 *     PluginURI?: string,
	 *     Version?: string,
	 *     Author?: string,
	 *     AuthorURI?: string,
	 *     TextDomain?: string,
	 *     DomainPath?: string,
	 *     Network?: bool,
	 *     RequiresWP?: string,
	 *     RequiresWC?: string,
	 *     RequiresPHP?: string,
	 *     TestedWP?: string,
	 *     TestedWC?: string,
	 *     UpdateURI?: string,
	 * }
	 */
	public function parse( string $plugin_file ): array {

		$plugin_data = $this->get_file_data( $plugin_file, self::HEADERS );

		if ( isset( $plugin_data['Network'] ) ) {
			$plugin_data['Network'] = filter_var( $plugin_data['Network'], \FILTER_VALIDATE_BOOLEAN );
		}

		// If no text domain is defined fall back to the plugin slug.
		if ( empty( $plugin_data['TextDomain'] ) ) {
			$plugin_slug = dirname( $plugin_file );
			if ( '.' !== $plugin_slug && false === strpos( $plugin_slug, '/' ) ) {
				$plugin_data['TextDomain'] = $plugin_slug;
			}
		}

		return $plugin_data;
	}

	/**
	 * Retrieves metadata from a file.
	 * Searches for metadata in the first 8 KB of a file, such as a plugin or theme.
	 * Each piece of metadata must be on its own line. Fields can not span multiple
	 * lines, the value will get cut at the end of the first line.
	 * If the file data is not within that first 8 KB, then the author should correct
	 * their plugin file and move the data headers to the top.
	 *
	 * @link  https://codex.wordpress.org/File_Header
	 * @since 2.9.0
	 *
	 * @param string $file Absolute path to the file.
	 * @param array $default_headers List of headers, in the format `array( 'HeaderKey' => 'Header
	 *                                Name' )`.
	 *
	 * @return string[] Array of file header values keyed by header name.
	 */
	private function get_file_data( string $file, array $default_headers ): array {
		// Pull only the first 8 KB of the file in.
		$file_data = file_get_contents( $file, false, null, 0, 8 * self::KB_IN_BYTES );

		if ( false === $file_data ) {
			$file_data = '';
		}

		// Make sure we catch CR-only line endings.
		$file_data = \str_replace( "\r", "\n", $file_data );

		$headers = [];
		foreach ( $default_headers as $field => $regex ) {
			if ( preg_match( '/^(?:[ \t]*<\?php)?[ \t\/*#@]*' . preg_quote( $regex, '/' ) . ':(.*)$/mi', $file_data, $match ) && $match[1] ) {
				$headers[ $field ] = $this->_cleanup_header_comment( $match[1] );
			}
		}

		return $headers;
	}

	/**
	 * Strips close comment and close php tags from file headers used by WP.
	 *
	 * @see    https://core.trac.wordpress.org/ticket/8497
	 */
	private function _cleanup_header_comment( string $str ): string {
		return trim( preg_replace( '/\s*(?:\*\/|\?>).*/', '', $str ) );
	}

}