<?php
/**
 * Shared module behaviour.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Modules;

use WPXero\MenuVerse\Frontend\Assets;
use WPXero\MenuVerse\Frontend\Tokens;
use WPXero\MenuVerse\Plugin;

defined( 'ABSPATH' ) || exit;

/**
 * Base class implementing the parts of the module contract that never differ
 * between modules: settings access, the render pipeline and asset handles.
 */
abstract class Abstract_Module implements Module {

	/**
	 * Tracks whether this module has already emitted markup in the request.
	 *
	 * @var bool
	 */
	protected $rendered = false;

	/**
	 * Whether assets can still be enqueued in the document head.
	 *
	 * Set by `resolve_render_paths()` once this request is known to render the
	 * module, which is what lets an above the fold module put its styles in
	 * the head instead of after the markup that needs them.
	 *
	 * @var bool
	 */
	protected $early_assets = false;

	/**
	 * This module's own schema, as its class returned it.
	 *
	 * Reading it executes a file that builds a large translated array, and the
	 * token pass asks for it on every render. Held per instance and per
	 * locale; the registry keeps its own copy because it applies the schema
	 * filters this path deliberately does not.
	 *
	 * @var array<string,array>
	 */
	private $own_schema = array();

	/**
	 * Returns the translated one line module description.
	 *
	 * @return string
	 */
	public static function description() {
		return '';
	}

	/**
	 * Registers hooks. Overridden by concrete modules.
	 *
	 * @return void
	 */
	public function boot() {}

	/**
	 * Registers frontend assets. Overridden by concrete modules.
	 *
	 * @return void
	 */
	public function register_assets() {}

	/**
	 * Returns the stored settings for this module.
	 *
	 * @return array
	 */
	public function settings() {
		return Plugin::instance()->settings()->get_module( static::id() );
	}

	/**
	 * Determines whether this module is enabled.
	 *
	 * @return bool
	 */
	public function is_enabled() {
		$settings = $this->settings();

		return ! empty( $settings['enabled'] );
	}

	/**
	 * Returns the script and style handle for this module.
	 *
	 * @return string
	 */
	public function handle() {
		return 'menuverse-' . str_replace( '_', '-', static::id() );
	}

	/**
	 * Reports whether the module has emitted markup during this request.
	 *
	 * @return bool
	 */
	public function has_rendered() {
		return $this->rendered;
	}

	/**
	 * Runs the full render pipeline for the module.
	 *
	 * Concrete modules implement `render()`; everything around it — settings
	 * resolution, extension hooks and asset enqueueing — lives here so every
	 * render path behaves identically.
	 *
	 * @param array $args    Per call settings overriding stored values.
	 * @param array $context Render context.
	 * @return string
	 */
	public function output( array $args = array(), array $context = array() ) {
		$module_id = static::id();
		$context   = wp_parse_args(
			$context,
			array(
				'source' => 'unknown',
			)
		);

		$settings = array_merge( $this->settings(), $args );

		/**
		 * Filters the settings used for a single render.
		 *
		 * Server side gating belongs here: an extension can strip or replace
		 * keys before any markup is produced.
		 *
		 * @since 1.0.0
		 *
		 * @param array  $settings  Resolved settings.
		 * @param string $module_id Module identifier.
		 * @param array  $context   Render context.
		 */
		$settings = apply_filters( 'menuverse_render_settings', $settings, $module_id, $context );

		/**
		 * Filters whether this render should happen at all.
		 *
		 * `menuverse_render_settings` can change what a module draws but not
		 * whether it draws, and the markup filter runs after the assets have
		 * already been queued. An extension deciding on the day, the visitor
		 * or a licence needs to say no before either — otherwise a module that
		 * renders nothing still ships its CSS and JS, which breaks the promise
		 * that a page showing no MenuVerse markup downloads no MenuVerse bytes.
		 *
		 * @since 1.0.0
		 *
		 * @param bool   $render    Whether to render.
		 * @param array  $settings  Resolved settings.
		 * @param string $module_id Module identifier.
		 * @param array  $context   Render context.
		 */
		if ( ! apply_filters( 'menuverse_should_render', true, $settings, $module_id, $context ) ) {
			return '';
		}

		/**
		 * Fires before a module renders.
		 *
		 * @since 1.0.0
		 *
		 * @param array $settings Resolved settings.
		 * @param array $context  Render context.
		 */
		do_action( "menuverse_{$module_id}_before_render", $settings, $context );

		$markup = $this->render( $settings, $context );

		if ( '' !== trim( (string) $markup ) ) {
			$this->rendered = true;
			$this->enqueue();
		}

		/**
		 * Filters the markup a module produced.
		 *
		 * @since 1.0.0
		 *
		 * @param string $markup   Rendered markup.
		 * @param array  $settings Resolved settings.
		 * @param array  $context  Render context.
		 */
		$markup = apply_filters( "menuverse_{$module_id}_markup", $markup, $settings, $context );

		/**
		 * Fires after a module renders.
		 *
		 * @since 1.0.0
		 *
		 * @param array $settings Resolved settings.
		 * @param array $context  Render context.
		 */
		do_action( "menuverse_{$module_id}_after_render", $settings, $context );

		return (string) $markup;
	}

	/**
	 * Queues CSS generated during a render.
	 *
	 * Per instance rules go through `wp_add_inline_style()` on the module
	 * handle, so nothing is ever echoed as a style tag.
	 *
	 * @param string $css Generated CSS.
	 * @return void
	 */
	protected function add_instance_css( $css ) {
		Plugin::instance()->assets()->add_inline_style( $this->handle(), $css );
	}

	/**
	 * Determines whether this module would actually emit markup.
	 *
	 * The early asset path has to decide whether to enqueue before `render()`
	 * has run, so it asks this instead. Without it an enabled module whose
	 * menu is empty or missing still ships its CSS and JS to every visitor
	 * while rendering nothing at all, which breaks the guarantee that a page
	 * showing no MenuVerse markup downloads no MenuVerse bytes.
	 *
	 * Modules that can come up empty override this. The default is permissive
	 * so a third party module never silently loses its assets.
	 *
	 * @param array $settings Resolved settings.
	 * @return bool
	 */
	public function has_content( array $settings ) {
		return true;
	}

	/**
	 * Enqueues the module's registered assets.
	 *
	 * Called from the render pipeline only, which is what makes conditional
	 * loading real rather than theoretical.
	 *
	 * @return void
	 */
	public function enqueue() {
		$handle = $this->handle();

		if ( wp_style_is( $handle, 'registered' ) ) {
			wp_enqueue_style( $handle );
		}

		if ( wp_script_is( $handle, 'registered' ) ) {
			wp_enqueue_script( $handle );
		}

		/**
		 * Fires when a module enqueues its assets.
		 *
		 * @since 1.0.0
		 *
		 * @param string $handle Module asset handle.
		 */
		do_action( 'menuverse_enqueue_module_assets', $handle );
	}


	/**
	 * Decides, before the head is printed, whether this request renders the
	 * module and queues its assets when it does.
	 *
	 * Every module answers the same four questions in the same order — is
	 * there anything to show, does the automatic placement apply, is the
	 * module written into this post, and may the assets still reach the head —
	 * so the sequence lives here and modules only supply the answers that
	 * genuinely differ: `auto_insert_hook()`, `auto_insert_priority()`,
	 * `passes_display_rules()` and `on_early_assets()`.
	 *
	 * @return void
	 */
	public function resolve_render_paths() {
		if ( is_admin() ) {
			return;
		}

		$settings = $this->settings();

		// Nothing to show means nothing to load. Checked before the early
		// asset path commits, not after render() has already returned ''.
		if ( ! $this->has_content( $settings ) ) {
			return;
		}

		$context = array( 'source' => 'early_assets' );

		// Resolved exactly as `output()` resolves them, and for the same
		// reason it does: this decision has to be made against the settings the
		// render will actually use. Asking with the raw stored values would let
		// a setting that is gated off — by a licence, or by anything else
		// hooked here — still decide whether the assets load, which is the one
		// place the two paths must not be allowed to disagree.
		$settings = apply_filters( 'menuverse_render_settings', $settings, static::id(), $context );

		// Asked with the same filter the render path uses, so an extension that
		// says no does not have to say it twice — and the assets stay off the
		// page rather than being queued for markup that never arrives.
		if ( ! apply_filters( 'menuverse_should_render', true, $settings, static::id(), $context ) ) {
			return;
		}

		if ( ! empty( $settings['auto_insert'] ) && $this->passes_display_rules( $settings ) ) {
			$this->early_assets = true;

			add_action(
				$this->auto_insert_hook(),
				array( $this, 'render_auto_insert' ),
				$this->auto_insert_priority()
			);
		} elseif ( $this->post_uses_module() ) {
			$this->early_assets = true;
		}

		if ( $this->early_assets ) {
			$this->on_early_assets();

			add_action( 'wp_enqueue_scripts', array( $this, 'enqueue' ), 20 );
		}
	}

	/**
	 * Returns the hook the automatic placement renders on.
	 *
	 * @return string
	 */
	protected function auto_insert_hook() {
		return 'wp_body_open';
	}

	/**
	 * Returns the priority the automatic placement renders at.
	 *
	 * Modules that can both be placed automatically share one hook, so the
	 * priority is how they keep a stable order relative to each other.
	 *
	 * @return int
	 */
	protected function auto_insert_priority() {
		return 10;
	}

	/**
	 * Runs once this request is known to render the module, before its assets
	 * are queued. Overridden by modules with work that must happen while the
	 * head is still open.
	 *
	 * @return void
	 */
	protected function on_early_assets() {}

	/**
	 * Decides whether the automatic placement may run on this request.
	 *
	 * Governs the automatic placement only — someone who placed the block or
	 * wrote the shortcode has already said where they want it. The default is
	 * permissive; a module with its own page rules overrides this.
	 *
	 * @param array $settings Resolved settings.
	 * @return bool
	 */
	protected function passes_display_rules( array $settings ) {
		return true;
	}

	/**
	 * Renders the module through the automatic insertion hook.
	 *
	 * @return void
	 */
	public function render_auto_insert() {
		if ( $this->has_rendered() ) {
			return;
		}

		// Markup is escaped as it is built inside the render pipeline.
		echo $this->output( array(), array( 'source' => 'auto_insert' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * Determines whether the queried content contains the block or shortcode.
	 *
	 * @return bool
	 */
	protected function post_uses_module() {
		// On an archive `get_post()` is whichever post the loop starts on, so
		// without this a listing page could enqueue assets for markup it never
		// renders.
		if ( ! is_singular() ) {
			return false;
		}

		$post = get_post();

		if ( ! $post instanceof \WP_Post ) {
			return false;
		}

		$shortcode = $this->optional_constant( 'SHORTCODE' );
		$block     = $this->optional_constant( 'BLOCK' );

		// Read defensively: neither constant is on the module interface, so a
		// third party module may declare only one of them, or neither.
		return ( '' !== $shortcode && has_shortcode( $post->post_content, $shortcode ) )
			|| ( '' !== $block && has_block( $block, $post ) );
	}

	/**
	 * Reads an optional class constant on the concrete module.
	 *
	 * @param string $constant Constant name.
	 * @return string Empty string when the module does not declare it.
	 */
	protected function optional_constant( $constant ) {
		$reference = static::class . '::' . $constant;

		return defined( $reference ) ? (string) constant( $reference ) : '';
	}

	/**
	 * Sanitizes per call overrides against the module schema.
	 *
	 * Shortcode attributes and block attributes are untrusted in the same way
	 * stored settings are, and they take the same route through the schema.
	 * Only the keys that were actually passed survive, so an override never
	 * silently reintroduces a stored default.
	 *
	 * @param array $args Raw overrides.
	 * @return array
	 */
	protected function clean( array $args ) {
		if ( empty( $args ) ) {
			return array();
		}

		$settings = Plugin::instance()->settings()->sanitize_module( static::id(), $args );

		if ( is_wp_error( $settings ) ) {
			return array();
		}

		return array_intersect_key( $settings, $args );
	}

	/**
	 * Returns the build directory basename for this module's assets.
	 *
	 * @return string
	 */
	protected function asset_slug() {
		return str_replace( '_', '-', static::id() );
	}

	/**
	 * Registers the module's built stylesheet and script without enqueueing.
	 *
	 * Each half is guarded on its own handle so a module that registers its
	 * stylesheet early — on `init`, to share it with the editor — still gets
	 * its script registered later.
	 *
	 * @param bool $with_style  Whether to register the stylesheet.
	 * @param bool $with_script Whether to register the script.
	 * @return void
	 */
	protected function register_module_assets( $with_style = true, $with_script = true ) {
		$assets = Plugin::instance()->assets();
		$handle = $this->handle();
		$slug   = $this->asset_slug();

		if ( $with_style && ! wp_style_is( $handle, 'registered' ) ) {
			$assets->register_base();

			$style = $assets->url( 'modules/' . $slug . '.css' );

			if ( $style ) {
				wp_register_style(
					$handle,
					$style,
					array( Assets::BASE_HANDLE ),
					$assets->version( 'modules/' . $slug . '.css' )
				);
				wp_style_add_data( $handle, 'rtl', 'replace' );
			}
		}

		if ( $with_script && ! wp_script_is( $handle, 'registered' ) ) {
			$script = $assets->url( 'modules/' . $slug . '.js' );

			if ( $script ) {
				$meta = $assets->script_meta( 'modules/' . $slug );

				wp_register_script( $handle, $script, $meta['dependencies'], $meta['version'], true );
				wp_script_add_data( $handle, 'strategy', 'defer' );
			}
		}
	}

	/**
	 * Builds the inline CSS custom property declarations for a render.
	 *
	 * Settings that declare a `ui.token` are written straight onto the module
	 * root, which removes the need for bespoke CSS emission per setting.
	 *
	 * A token written here lands in the `style` attribute, which outranks every
	 * author stylesheet rule including one inside a media query. A property
	 * whose value has to change with the colour scheme therefore cannot be
	 * emitted here at all — pass it in `$exclude` and emit it from
	 * `instance_css()` instead, where both schemes compete on equal terms.
	 *
	 * @param array    $settings Resolved settings.
	 * @param string[] $exclude  Property keys to leave out.
	 * @return string Value for a `style` attribute, unescaped.
	 */
	protected function style_tokens( array $settings, array $exclude = array() ) {
		$key = determine_locale();

		if ( ! isset( $this->own_schema[ $key ] ) ) {
			$this->own_schema[ $key ] = (array) static::schema();
		}

		return Tokens::declarations( $this->own_schema[ $key ], $settings, $exclude );
	}
}
