<?php
/**
 * Frontend asset orchestration.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Frontend;

use WPXero\MenuVerse\Settings\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Registers shared frontend assets and resolves build metadata.
 *
 * Nothing here enqueues anything. Modules enqueue from inside their render
 * pipeline, so a page that renders no MenuVerse markup ships no MenuVerse
 * bytes.
 */
final class Assets {

	/**
	 * Handle for the shared token stylesheet every module depends on.
	 *
	 * @var string
	 */
	const BASE_HANDLE = 'menuverse-base';

	/**
	 * Handle used to print CSS that is generated after the head was rendered.
	 *
	 * Registered without a source: it exists purely as a carrier for inline
	 * styles, which is what lets render time CSS go through the style queue
	 * instead of being echoed as a style tag.
	 *
	 * @var string
	 */
	const INLINE_HANDLE = 'menuverse-inline';

	/**
	 * Hashes of inline CSS already added during this request.
	 *
	 * @var array<string,bool>
	 */
	private $inline_seen = array();

	/**
	 * Settings store.
	 *
	 * @var Settings
	 */
	private $settings;

	/**
	 * Constructor.
	 *
	 * @param Settings $settings Settings store.
	 */
	public function __construct( Settings $settings ) {
		$this->settings = $settings;
	}

	/**
	 * Registers hooks.
	 *
	 * @return void
	 */
	public function boot() {
		add_action( 'wp_enqueue_scripts', array( $this, 'register_base' ), 4 );
		add_action( 'wp_enqueue_scripts', array( $this, 'register_inline_carrier' ), 4 );
	}

	/**
	 * Registers the shared token stylesheet.
	 *
	 * @return void
	 */
	public function register_base() {
		$style = $this->url( 'shared/base.css' );

		if ( ! $style ) {
			return;
		}

		wp_register_style( self::BASE_HANDLE, $style, array(), $this->version( 'shared/base.css' ) );
		wp_style_add_data( self::BASE_HANDLE, 'rtl', 'replace' );

		$tokens = $this->global_tokens();

		if ( '' !== $tokens ) {
			wp_add_inline_style( self::BASE_HANDLE, ':root{' . $tokens . '}' );
		}
	}

	/**
	 * Registers the source less handle that carries late inline styles.
	 *
	 * @return void
	 */
	public function register_inline_carrier() {
		if ( wp_style_is( self::INLINE_HANDLE, 'registered' ) ) {
			return;
		}

		wp_register_style( self::INLINE_HANDLE, false, array(), MENUVERSE_VERSION );
	}

	/**
	 * Adds generated CSS to the style queue.
	 *
	 * Modules build per instance CSS while rendering, which is long after the
	 * head has been printed on most render paths. The rules are attached to the
	 * module handle when that handle has not been printed yet, and to the
	 * source less carrier handle otherwise, so WordPress prints them with
	 * `print_late_styles()` rather than the plugin echoing a style tag.
	 *
	 * @param string $handle Preferred style handle.
	 * @param string $css    Generated CSS.
	 * @return bool Whether the CSS was queued.
	 */
	public function add_inline_style( $handle, $css ) {
		$css = trim( (string) $css );

		if ( '' === $css ) {
			return false;
		}

		$key = md5( $handle . '|' . $css );

		if ( isset( $this->inline_seen[ $key ] ) ) {
			return true;
		}

		$target = (string) $handle;

		if ( '' === $target || ! wp_style_is( $target, 'registered' ) || wp_style_is( $target, 'done' ) ) {
			$target = $this->inline_carrier();
		}

		if ( '' === $target ) {
			return false;
		}

		$this->inline_seen[ $key ] = true;

		return (bool) wp_add_inline_style( $target, $css );
	}

	/**
	 * Returns the carrier handle, enqueueing it on demand.
	 *
	 * @return string Handle, or an empty string when styles have already been printed.
	 */
	private function inline_carrier() {
		$this->register_inline_carrier();

		if ( wp_style_is( self::INLINE_HANDLE, 'done' ) ) {
			return '';
		}

		wp_enqueue_style( self::INLINE_HANDLE );

		return self::INLINE_HANDLE;
	}

	/**
	 * Builds the plugin wide CSS custom property declarations.
	 *
	 * @return string
	 */
	public function global_tokens() {
		return Tokens::declarations( $this->settings->global_schema(), $this->settings->all()['global'] );
	}

	/**
	 * Returns the absolute URL for a built asset, or an empty string.
	 *
	 * @param string $relative Path relative to the build directory.
	 * @return string
	 */
	public function url( $relative ) {
		if ( ! file_exists( MENUVERSE_PATH . 'build/' . $relative ) ) {
			return '';
		}

		return MENUVERSE_URL . 'build/' . $relative;
	}

	/**
	 * Returns the cache busting version for a built asset.
	 *
	 * @param string $relative Path relative to the build directory.
	 * @return string
	 */
	public function version( $relative ) {
		$file = MENUVERSE_PATH . 'build/' . $relative;

		if ( file_exists( $file ) ) {
			return (string) filemtime( $file );
		}

		return MENUVERSE_VERSION;
	}

	/**
	 * Reads the dependency manifest webpack emits beside each JS entry.
	 *
	 * @param string $entry Entry name relative to the build directory, without extension.
	 * @return array{dependencies:string[],version:string}
	 */
	public function script_meta( $entry ) {
		$manifest = MENUVERSE_PATH . 'build/' . $entry . '.asset.php';

		if ( file_exists( $manifest ) ) {
			$meta = include $manifest;

			if ( is_array( $meta ) ) {
				return array(
					'dependencies' => isset( $meta['dependencies'] ) ? (array) $meta['dependencies'] : array(),
					'version'      => isset( $meta['version'] ) ? (string) $meta['version'] : MENUVERSE_VERSION,
				);
			}
		}

		return array(
			'dependencies' => array(),
			'version'      => MENUVERSE_VERSION,
		);
	}
}
