<?php
namespace AIOSEO\Plugin\Common\Traits;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Assets trait.
 *
 * @since 4.1.9
 */
trait Assets {
	/**
	 * Whether we should load dev scripts.
	 *
	 * @since 4.1.9
	 *
	 * @var boolean|null
	 */
	private $shouldLoadDevScripts = null;

	/**
	 * Holds the location of the manifest file.
	 *
	 * @since 4.1.9
	 *
	 * @var string
	 */
	private $manifestFile;

	/**
	 * True if we are in a dev environment. This mirrors the global isDev.
	 *
	 * @since 4.1.9
	 *
	 * @var bool
	 */
	private $isDev = false;

	/**
	 * Asset handles that should load as regular JS and not as modern JS module.
	 *
	 * @since 4.1.9
	 *
	 * @var array An array of handles.
	 */
	private $noModuleTag = [];

	/**
	 * Core class instance.
	 *
	 * @since 4.2.7
	 *
	 * @var \AIOSEO\Plugin\Common\Core\Core
	 */
	protected $core = null;

	/**
	 * The LocalBusiness addon version.
	 *
	 * @since 4.2.7
	 *
	 * @var string
	 */
	protected $version = '';

	/**
	 * The development site domain.
	 *
	 * @since 4.2.7
	 *
	 * @var string
	 */
	protected $domain = '';

	/**
	 * The development server port.
	 *
	 * @since 4.2.7
	 *
	 * @var int
	 */
	protected $port = 0;

	/**
	 * The asset to load.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset        The asset to load.
	 * @param  array  $dependencies An array of dependencies.
	 * @param  mixed  $data         Any data to be localized.
	 * @param  string $objectName   The object name to use when localizing.
	 * @return void
	 */
	public function load( $asset, $dependencies = [], $data = null, $objectName = 'aioseo' ) {
		$this->jsPreloadImports( $asset );
		$this->loadCss( $asset );
		$this->enqueueJs( $asset, $dependencies, $data, $objectName );
	}

	/**
	 * Filter the script loader tag if this is our script.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $tag    The tag that is going to be output.
	 * @param  string $handle The handle for the script.
	 * @return string         The modified tag.
	 */
	public function scriptLoaderTag( $tag, $handle = '', $src = '' ) {
		if ( $this->skipModuleTag( $handle ) ) {
			return $tag;
		}

		$tag = str_replace( $src, $this->normalizeAssetsHost( $src ), $tag );

		// Remove the type and re-add it as module.
		$tag = preg_replace( '/type=[\'"].*?[\'"]/', '', (string) $tag );
		$tag = preg_replace( '/<script/', '<script type="module"', (string) $tag );

		return $tag;
	}

	/**
	 * Preload JS imports.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to load imports for.
	 * @return void
	 */
	private function jsPreloadImports( $asset ) {
		static $urls = []; // Prevent script from being loaded multiple times.

		$res = '';
		foreach ( $this->importsUrls( $asset ) as $url ) {
			if ( isset( $urls[ $url ] ) ) {
				continue;
			}

			$urls[ $url ] = true;

			$res .= '<link rel="modulepreload" href="' . esc_attr( $url ) . "\">\n";
		}

		$allowedHtml = [
			'link' => [
				'rel'  => [],
				'href' => []
			]
		];

		if ( ! empty( $res ) ) {
			if ( ! function_exists( 'wp_enqueue_script_module' ) ) {
				add_action( 'admin_head', function () use ( &$res, $allowedHtml ) {
					echo wp_kses( $res, $allowedHtml );
				} );
				add_action( 'wp_head', function () use ( &$res, $allowedHtml ) {
					echo wp_kses( $res, $allowedHtml );
				} );
			} else {
				add_action( 'admin_print_footer_scripts', function () use ( &$res, $allowedHtml ) {
					echo wp_kses( $res, $allowedHtml );
				}, 1000 );
			}
		}
	}

	/**
	 * Loads CSS for an asset from the manifest file.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The script to load CSS for.
	 * @return void
	 */
	public function loadCss( $asset ) {
		if ( $this->shouldLoadDev() ) {
			return;
		}

		foreach ( $this->getCssUrls( $asset ) as $file => $url ) {
			wp_enqueue_style( $this->cssHandle( $file ), $url, [], $this->version );
		}
	}

	/**
	 * Register a CSS asset.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset        The script to load CSS for.
	 * @param  array  $dependencies An array of dependencies.
	 * @return void
	 */
	public function registerCss( $asset, $dependencies = [] ) {
		$handle = $this->cssHandle( $asset );
		if ( wp_style_is( $handle, 'registered' ) ) {
			return;
		}

		$url = $this->shouldLoadDev()
			? $this->getDevUrl() . ltrim( $asset, '/' )
			: $this->assetUrl( $asset );

		if ( ! $url ) {
			return;
		}

		wp_register_style( $handle, $url, $dependencies, $this->version );
	}

	/**
	 * Enqueue css.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset        The css to load.
	 * @param  array  $dependencies An array of dependencies.
	 * @return void
	 */
	public function enqueueCss( $asset, $dependencies = [] ) {
		$this->registerCss( $asset, $dependencies );

		$handle = $this->cssHandle( $asset );
		if ( wp_style_is( $handle, 'enqueued' ) ) {
			return;
		}

		wp_enqueue_style( $handle );
	}

	/**
	 * Register the JS to enqueue.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset        The script to load.
	 * @param  array  $dependencies An array of dependencies.
	 * @param  mixed  $data         Any data to be localized.
	 * @param  string $objectName   The object name to use when localizing.
	 * @return void
	 */
	public function registerJs( $asset, $dependencies = [], $data = null, $objectName = 'aioseo' ) {
		$handle = $this->jsHandle( $asset );
		if ( wp_script_is( $handle, 'registered' ) ) {
			// If it's already registered let's add the data.
			if ( ! empty( $data ) ) {
				wp_localize_script(
					$handle,
					$objectName,
					$data
				);
			}

			return;
		}

		$url = $this->shouldLoadDev()
			? $this->getDevUrl() . ltrim( $asset, '/' )
			: $this->jsUrl( $asset );

		if ( ! $url ) {
			return;
		}

		wp_register_script( $handle, $url, $dependencies, $this->version, true );

		if ( empty( $data ) ) {
			return;
		}

		wp_localize_script(
			$handle,
			$objectName,
			$data
		);
	}

	/**
	 * Register the JS to enqueue.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset        The script to load.
	 * @param  array  $dependencies An array of dependencies.
	 * @param  mixed  $data         Any data to be localized.
	 * @param  string $objectName   The object name to use when localizing.
	 * @return void
	 */
	public function enqueueJs( $asset, $dependencies = [], $data = null, $objectName = 'aioseo' ) {
		$this->registerJs( $asset, $dependencies, $data, $objectName );

		$handle = $this->jsHandle( $asset );
		if ( wp_script_is( $handle, 'enqueued' ) ) {
			return;
		}

		wp_enqueue_script( $handle );
	}

	/**
	 * Return the dev URL.
	 *
	 * @since 4.1.9
	 *
	 * @return string The dev URL.
	 */
	private function getDevUrl() {
		$protocol = is_ssl() ? 'https://' : 'http://';

		return $protocol . $this->domain . ':' . $this->port . '/';
	}

	/**
	 * Get the asset URL.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to find the URL for.
	 * @return string        The URL for the asset.
	 */
	private function assetUrl( $asset ) {
		$assetManifest = $this->getAssetManifestItem( $asset );

		return ! empty( $assetManifest['file'] )
			? $this->basePath() . $assetManifest['file']
			: $this->basePath() . ltrim( $asset, '/' );
	}

	/**
	 * Get the JS URL.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to find the URL for.
	 * @return string        The URL for the asset.
	 */
	public function jsUrl( $asset ) {
		$manifestAsset = $this->getManifestItem( $asset );

		return ! empty( $manifestAsset['file'] )
			? $this->basePath() . $manifestAsset['file']
			: $this->basePath() . ltrim( $asset, '/' );
	}

	/**
	 * Get an item from the manifest.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to find.
	 * @return string        Manifest object.
	 */
	private function getManifestItem( $asset ) {
		$manifest = $this->getManifest();

		$asset = ltrim( $asset, '/' );

		return isset( $manifest[ $asset ] ) ? $manifest[ $asset ] : null;
	}

	/**
	 * Get the CSS asset handle.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to find the handle for.
	 * @return string        The asset handle.
	 */
	public function cssHandle( $asset ) {
		return "{$this->scriptHandle}/css/$asset";
	}

	/**
	 * Get the JS asset handle.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to find the handle for.
	 * @return string        The asset handle.
	 */
	public function jsHandle( $asset = '' ) {
		return "{$this->scriptHandle}/js/$asset";
	}

	/**
	 * Get the manifest to load assets from.
	 *
	 * @since 4.1.9
	 *
	 * @return array An array of files.
	 */
	private function getManifest() {
		static $file = null;
		if ( $file ) {
			return $file;
		}

		$manifestJson = ''; // This is set in the view.

		if ( file_exists( $this->manifestFile ) ) {
			require_once $this->manifestFile;
		}

		$file = json_decode( $manifestJson, true );

		return $file;
	}

	/**
	 * Get an item from the asset manifest.
	 *
	 * @since 4.1.9
	 *
	 * @param  string      $item An item to retrieve.
	 * @return string|null       The asset item.
	 */
	public function getAssetManifestItem( $item ) {
		$assetManifest = $this->getManifest();

		return ! empty( $assetManifest[ $item ] ) ? $assetManifest[ $item ] : null;
	}

	/**
	 * Get an asset's array of URLs to import.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to find imports for.
	 * @return array         An array of imports.
	 */
	private function importsUrls( $asset ) {
		$urls          = [];
		$manifestAsset = $this->getManifestItem( $asset );
		if ( ! empty( $manifestAsset['imports'] ) ) {
			foreach ( $manifestAsset['imports'] as $import ) {
				$importAsset = $this->getManifestItem( $import );
				if ( ! empty( $importAsset['file'] ) ) {
					$urls[] = $this->getPublicUrlBase() . $importAsset['file'];

					// Load the import's CSS if any.
					$this->loadCss( $import );
				}
			}
		}

		return $urls;
	}

	/**
	 * Returns an asset's CSS urls.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $asset The asset to find CSS URLs for.
	 * @return array         An array of CSS URLs to load.
	 */
	private function getCssUrls( $asset ) {
		$urls          = [];
		$manifestAsset = $this->getManifestItem( $asset );

		if ( ! empty( $manifestAsset['css'] ) ) {
			foreach ( $manifestAsset['css'] as $file ) {
				$urls[ $file ] = $this->getPublicUrlBase() . $file;
			}
		}

		return $urls;
	}

	/**
	 * Check if we should load the dev watcher scripts.
	 *
	 * @since 4.1.9
	 *
	 * @return boolean True if we should load the dev watcher scripts.
	 */
	private function shouldLoadDev() {
		if ( null !== $this->shouldLoadDevScripts ) {
			return $this->shouldLoadDevScripts;
		}

		if (
			! $this->isDev ||
			(
				defined( 'AIOSEO_LOAD_DEV_SCRIPTS' ) &&
				false === AIOSEO_LOAD_DEV_SCRIPTS
			)
		) {
			$this->shouldLoadDevScripts = false;

			return $this->shouldLoadDevScripts;
		}

		if ( ! $this->domain && ! $this->port ) {
			$this->shouldLoadDevScripts = false;

			return $this->shouldLoadDevScripts;
		}

		set_error_handler( function() {} ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
		$connection = fsockopen( $this->domain, $this->port ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		restore_error_handler();

		if ( ! $connection ) {
			$this->shouldLoadDevScripts = false;

			return $this->shouldLoadDevScripts;
		}

		$this->shouldLoadDevScripts = true;

		return $this->shouldLoadDevScripts;
	}

	/**
	 * Get the path for the assets.
	 *
	 * @since 4.1.9
	 *
	 * @param  bool   $maybeDev Whether to try and load dev scripts.
	 * @return string           The path for the assets.
	 */
	public function getAssetsPath( $maybeDev = true ) {
		return $maybeDev && $this->shouldLoadDev()
			? $this->getDevUrl()
			: $this->basePath();
	}

	/**
	 * Finds out if a handle should be loaded as regular JS and not as modern JS module.
	 *
	 * @since 4.1.9
	 *
	 * @param  string $handle The script handle.
	 * @return bool           Should the module tag be skipped.
	 */
	public function skipModuleTag( $handle ) {
		if ( ! aioseo()->helpers->stringContains( $handle, $this->jsHandle( '' ) ) ) {
			return true;
		}

		foreach ( $this->noModuleTag as $tag ) {
			if ( aioseo()->helpers->stringContains( $handle, $tag ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Normalize the assets host. Some sites manually set the WP_PLUGINS_URL
	 * and if that domain has www. and the site_url does not, then it will fail to load
	 * our assets. This doesn't fix the issue 100% because it will still fail on
	 * sub-domains that don't have the proper CORS headers. Those sites will need
	 * manual fixes.
	 *
	 * @since 4.1.10
	 *
	 * @param  string $path The path to normalize.
	 * @return string       The normalized path.
	 */
	public function normalizeAssetsHost( $path ) {
		static $paths = [];
		if ( isset( $paths[ $path ] ) ) {
			return apply_filters( 'aioseo_normalize_assets_host', $paths[ $path ] );
		}

		// We need to verify the domain on the $path attribute matches
		// what's in site_url() for our assets or they won't load.
		$siteUrl        = site_url();
		$siteUrlEscaped = aioseo()->helpers->escapeRegex( $siteUrl );
		if ( preg_match( "/^$siteUrlEscaped/i", (string) $path ) ) {
			$paths[ $path ] = $path;

			return apply_filters( 'aioseo_normalize_assets_host', $paths[ $path ] );
		}

		// We now know that the path doesn't contain the site_url().
		$newPath        = $path;
		$siteUrlParsed  = wp_parse_url( $siteUrl );
		$host           = aioseo()->helpers->escapeRegex( str_replace( 'www.', '', $siteUrlParsed['host'] ) );
		$scheme         = aioseo()->helpers->escapeRegex( $siteUrlParsed['scheme'] );

		$siteUrlHasWww = preg_match( "/^{$scheme}:\/\/www\.$host/", (string) $siteUrl );
		$pathHasWww    = preg_match( "/^{$scheme}:\/\/www\.$host/", (string) $path );

		// Check if the path contains www.
		if ( $pathHasWww && ! $siteUrlHasWww ) {
			// If the path contains www., we want to strip it out.
			$newPath = preg_replace( "/^({$scheme}:\/\/)(www\.)($host)/", '$1$3', (string) $path );
		}

		// Check if the site_url contains www.
		if ( $siteUrlHasWww && ! $pathHasWww ) {
			// If the site_url contains www., we want to add it in to the path.
			$newPath = preg_replace( "/^({$scheme}:\/\/)($host)/", '$1www.$2', (string) $path );
		}

		$paths[ $path ] = $newPath;

		return apply_filters( 'aioseo_normalize_assets_host', $paths[ $path ] );
	}

	/**
	 * Get all the CSS files which a JS asset depends on.
	 * This won't work properly unless you've run `npm run build` first.
	 *
	 * @since 4.3.1
	 *
	 * @param  string $asset The asset to find the CSS dependencies for.
	 * @return array         All the asset's CSS dependencies if any.
	 */
	public function getJsAssetCssQueue( $asset ) {
		$queue = [];

		foreach ( $this->getCssUrls( $asset ) as $file => $url ) {
			$queue[] = [
				'handle' => $this->cssHandle( $file ),
				'url'    => $url
			];
		}

		$manifestAsset = $this->getManifestItem( $asset );
		if ( ! empty( $manifestAsset['imports'] ) ) {
			foreach ( $manifestAsset['imports'] as $subAsset ) {
				foreach ( $this->getCssUrls( $subAsset ) as $file => $url ) {
					$queue[] = [
						'handle' => $this->cssHandle( $file ),
						'url'    => $url
					];
				}
			}
		}

		return $queue;
	}

	/**
	 * Check if an asset exists (works in both dev and production mode).
	 *
	 * @since 4.9.0
	 *
	 * @param  string $asset The asset path to check.
	 * @return bool          Whether the asset exists.
	 */
	public function assetExists( $asset ) {
		// In dev mode, check if the source file exists instead of checking the manifest.
		if ( $this->shouldLoadDev() ) {
			$sourcePath = AIOSEO_DIR . '/' . ltrim( $asset, '/' );

			return file_exists( $sourcePath );
		}

		// In production mode, check the manifest.
		return ! empty( aioseo()->core->assets->getAssetManifestItem( $asset ) );
	}
}