<?php
/**
 * Module registry.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Modules;

use WPXero\MenuVerse\Settings\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Holds registered module classes and instantiates only what is needed.
 *
 * Registration stores a class name, never an instance. A disabled module is
 * therefore never constructed and its file is never autoloaded on the
 * frontend, which is what the "zero code paths" guarantee rests on.
 */
final class Module_Registry {

	/**
	 * Registered module class names keyed by module id.
	 *
	 * @var array<string,string>
	 */
	private $classes = array();

	/**
	 * Instantiated modules keyed by module id.
	 *
	 * @var array<string,Module>
	 */
	private $instances = array();

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

	/**
	 * Schema definitions as the module classes returned them, before filters.
	 *
	 * A module's `schema()` reads a PHP file that builds a large translated
	 * array, and it is asked for that array many times in a request — once per
	 * settings read, once per render for the style tokens, once per dashboard
	 * definition. The file is executed once per class here; the filters still
	 * run on every call, so nothing that could change between calls is cached.
	 *
	 * Keyed by class name and locale, because a request that switches locale
	 * must not be served the strings translated for the previous one.
	 *
	 * @var array<string,array>
	 */
	private $raw_schemas = array();

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

	/**
	 * Registers a module class.
	 *
	 * @param string $class_name Fully qualified class implementing Module.
	 * @return bool True when the module was registered.
	 */
	public function register( $class_name ) {
		if ( ! is_string( $class_name ) || ! class_exists( $class_name ) ) {
			return false;
		}

		if ( ! is_subclass_of( $class_name, Module::class ) ) {
			return false;
		}

		$module_id = call_user_func( array( $class_name, 'id' ) );

		if ( ! is_string( $module_id ) || ! preg_match( '/^[a-z][a-z0-9_]*$/', $module_id ) ) {
			return false;
		}

		if ( isset( $this->classes[ $module_id ] ) ) {
			return false;
		}

		$this->classes[ $module_id ] = $class_name;

		return true;
	}

	/**
	 * Determines whether a module id is registered.
	 *
	 * @param string $module_id Module identifier.
	 * @return bool
	 */
	public function has( $module_id ) {
		return isset( $this->classes[ $module_id ] );
	}

	/**
	 * Returns every registered module id.
	 *
	 * @return string[]
	 */
	public function ids() {
		return array_keys( $this->classes );
	}

	/**
	 * Returns every module schema keyed by module id.
	 *
	 * Schemas are read statically, so this never instantiates a module.
	 *
	 * @return array<string,array>
	 */
	public function schemas() {
		$schemas = array();

		foreach ( $this->classes as $module_id => $class_name ) {
			$schemas[ $module_id ] = $this->schema( $module_id, $class_name );
		}

		return $schemas;
	}

	/**
	 * Returns the schema for a single module.
	 *
	 * @param string      $module_id  Module identifier.
	 * @param string|null $class_name Optional class name, resolved when omitted.
	 * @return array
	 */
	public function schema( $module_id, $class_name = null ) {
		$class_name = $class_name ? $class_name : ( isset( $this->classes[ $module_id ] ) ? $this->classes[ $module_id ] : null );

		if ( ! $class_name ) {
			return array();
		}

		$schema = $this->raw_schema( $class_name );

		if ( ! isset( $schema['type'] ) ) {
			$schema['type'] = 'object';
		}

		if ( ! isset( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) {
			$schema['properties'] = array();
		}

		// Every module is toggleable, so the registry owns this property
		// rather than asking each module to repeat it.
		if ( ! isset( $schema['properties']['enabled'] ) ) {
			$schema['properties'] = array_merge(
				array(
					'enabled' => array(
						'type'    => 'boolean',
						'default' => false,
						'ui'      => array(
							'control' => 'toggle',
							'group'   => 'general',
							'label'   => __( 'Enable module', 'menuverse' ),
						),
					),
				),
				$schema['properties']
			);
		}

		/**
		 * Filters a module's settings schema.
		 *
		 * Adding a property here gives it storage, sanitization, validation
		 * and an admin control with no further code.
		 *
		 * @since 1.0.0
		 *
		 * @param array  $schema    Schema definition.
		 * @param string $module_id Module identifier.
		 */
		return apply_filters( "menuverse_{$module_id}_schema", $schema, $module_id );
	}

	/**
	 * Returns a module class's own schema, executing its file once per request.
	 *
	 * @param string $class_name Fully qualified module class name.
	 * @return array
	 */
	private function raw_schema( $class_name ) {
		$key = $class_name . '|' . determine_locale();

		if ( ! isset( $this->raw_schemas[ $key ] ) ) {
			$this->raw_schemas[ $key ] = (array) call_user_func( array( $class_name, 'schema' ) );
		}

		return $this->raw_schemas[ $key ];
	}

	/**
	 * Returns the dashboard facing definition of every module.
	 *
	 * @return array[]
	 */
	public function definitions() {
		$definitions = array();

		foreach ( $this->classes as $module_id => $class_name ) {
			$enabled = $this->is_enabled( $module_id );

			$definitions[] = array(
				'id'          => $module_id,
				'label'       => (string) call_user_func( array( $class_name, 'label' ) ),
				'description' => (string) call_user_func( array( $class_name, 'description' ) ),
				'enabled'     => $enabled,
				// A module still being finished is listed so people can see
				// what is coming, but it cannot be switched on and never
				// runs. The dashboard reads this to say so plainly instead of
				// offering a form that would do nothing.
				'available'   => $this->is_available( $module_id ),
				// A released module that is still settling. Unlike
				// `available` this changes nothing about what runs — it is a
				// label, so somebody choosing between two modules knows which
				// one may still move under them.
				'beta'        => $this->is_beta( $module_id ),
				// Whether the module would currently emit anything, so the
				// dashboard can stop claiming a menu is live when its chosen
				// menu is empty. Only resolved for enabled modules, which are
				// instantiated by boot_enabled() anyway — a disabled module
				// is still never constructed.
				'hasContent'  => $enabled ? $this->module_has_content( $module_id ) : null,
				'schema'      => $this->schema( $module_id, $class_name ),
				// Read defensively: the interface does not demand either
				// constant, so a third party module may define neither.
				'shortcode'   => self::class_constant( $class_name, 'SHORTCODE' ),
				'block'       => self::class_constant( $class_name, 'BLOCK' ),
			);
		}

		/**
		 * Filters the module definitions exposed to the dashboard.
		 *
		 * @since 1.0.0
		 *
		 * @param array[] $definitions Module definitions.
		 */
		return apply_filters( 'menuverse_module_definitions', $definitions );
	}

	/**
	 * Asks an enabled module whether it would currently render anything.
	 *
	 * @param string $module_id Module identifier.
	 * @return bool
	 */
	private function module_has_content( $module_id ) {
		$module = $this->get( $module_id );

		// Not on the Module interface: a third party module may implement the
		// interface directly rather than extending Abstract_Module, and it
		// should keep working rather than fatal here.
		if ( ! $module instanceof Module || ! method_exists( $module, 'has_content' ) ) {
			return true;
		}

		return (bool) $module->has_content( $this->settings->get_module( $module_id ) );
	}

	/**
	 * Reads an optional class constant without tripping on modules that do
	 * not define it.
	 *
	 * @param string $class_name Fully qualified class name.
	 * @param string $constant   Constant name.
	 * @return string
	 */
	private static function class_constant( $class_name, $constant ) {
		$reference = $class_name . '::' . $constant;

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

	/**
	 * Determines whether a module is released but still settling.
	 *
	 * A module says so with a `BETA` class constant, read statically like
	 * `COMING_SOON` so the dashboard can label a module it never constructs.
	 * Unlike `COMING_SOON` this gates nothing: a beta module boots, renders
	 * and saves exactly like any other. The only thing it changes is what the
	 * dashboard says about it, because "this works but may still change" and
	 * "this does not work yet" are different promises and a plugin that
	 * conflates them teaches people to ignore both.
	 *
	 * @param string $module_id Module identifier.
	 * @return bool
	 */
	public function is_beta( $module_id ) {
		if ( ! $this->has( $module_id ) ) {
			return false;
		}

		$reference = $this->classes[ $module_id ] . '::BETA';
		$beta      = defined( $reference ) && constant( $reference );

		/**
		 * Filters whether a module is labelled as a beta.
		 *
		 * @since 1.0.0
		 *
		 * @param bool   $beta      Whether the module is still settling.
		 * @param string $module_id Module identifier.
		 */
		return (bool) apply_filters( 'menuverse_module_beta', $beta, $module_id );
	}

	/**
	 * Determines whether a module has been released yet.
	 *
	 * A module declares itself unfinished with a `COMING_SOON` class constant.
	 * Read defensively, like the other optional constants: a module that does
	 * not mention it — which is every third party one — is available.
	 *
	 * @param string $module_id Module identifier.
	 * @return bool
	 */
	public function is_available( $module_id ) {
		if ( ! $this->has( $module_id ) ) {
			return false;
		}

		$reference = $this->classes[ $module_id ] . '::COMING_SOON';
		$available = ! ( defined( $reference ) && constant( $reference ) );

		/**
		 * Filters whether a module is available to be used.
		 *
		 * @since 1.0.0
		 *
		 * @param bool   $available Whether the module has been released.
		 * @param string $module_id Module identifier.
		 */
		return (bool) apply_filters( 'menuverse_module_available', $available, $module_id );
	}

	/**
	 * Tells whether any usable module's dashboard screen embeds the block editor.
	 *
	 * A module declares the need with a `NEEDS_BLOCK_EDITOR` class constant,
	 * read the same defensive way as `COMING_SOON`: a module that does not
	 * mention it does not need it.
	 *
	 * Availability is part of the question, not a separate check. A module
	 * that has not shipped has no settings screen to reach, so a need it
	 * declares cannot be acted on — and the block editor is heavy enough that
	 * loading it for an unreachable screen is the single most expensive thing
	 * the dashboard could do.
	 *
	 * @return bool
	 */
	public function needs_block_editor() {
		$needed = false;

		foreach ( array_keys( $this->classes ) as $module_id ) {
			if ( ! $this->is_available( $module_id ) ) {
				continue;
			}

			$reference = $this->classes[ $module_id ] . '::NEEDS_BLOCK_EDITOR';

			if ( defined( $reference ) && constant( $reference ) ) {
				$needed = true;
				break;
			}
		}

		/**
		 * Filters whether the dashboard loads the block editor.
		 *
		 * Filtered after the answer is worked out rather than only when it
		 * came back false, so an add on whose own screen needs the editor can
		 * ask for it — and one that has replaced the panel editor with
		 * something lighter can decline it.
		 *
		 * @since 1.0.0
		 *
		 * @param bool $needed Whether any usable module embeds the block editor.
		 */
		return (bool) apply_filters( 'menuverse_needs_block_editor', $needed );
	}

	/**
	 * Determines whether a module is enabled.
	 *
	 * @param string $module_id Module identifier.
	 * @return bool
	 */
	public function is_enabled( $module_id ) {
		if ( ! $this->has( $module_id ) ) {
			return false;
		}

		// Checked before the stored flag rather than alongside it: this is the
		// one place boot_enabled() and render() both pass through, so an
		// unavailable module is inert everywhere by holding the line here.
		// The saved value is read but not honoured, so it survives until the
		// module ships.
		if ( ! $this->is_available( $module_id ) ) {
			return false;
		}

		$settings = $this->settings->get_module( $module_id );
		$enabled  = ! empty( $settings['enabled'] );

		/**
		 * Filters whether a module is enabled.
		 *
		 * @since 1.0.0
		 *
		 * @param bool   $enabled   Whether the module is enabled.
		 * @param string $module_id Module identifier.
		 */
		return (bool) apply_filters( 'menuverse_module_enabled', $enabled, $module_id );
	}

	/**
	 * Returns a module instance, constructing it on first use.
	 *
	 * @param string $module_id Module identifier.
	 * @return Module|null
	 */
	public function get( $module_id ) {
		if ( isset( $this->instances[ $module_id ] ) ) {
			return $this->instances[ $module_id ];
		}

		if ( ! $this->has( $module_id ) ) {
			return null;
		}

		$class_name = $this->classes[ $module_id ];

		$this->instances[ $module_id ] = new $class_name();

		return $this->instances[ $module_id ];
	}

	/**
	 * Boots every enabled module.
	 *
	 * @return void
	 */
	public function boot_enabled() {
		foreach ( array_keys( $this->classes ) as $module_id ) {
			if ( ! $this->is_enabled( $module_id ) ) {
				continue;
			}

			$module = $this->get( $module_id );

			if ( $module instanceof Module ) {
				$module->boot();

				add_action( 'wp_enqueue_scripts', array( $module, 'register_assets' ), 5 );
			}
		}
	}

	/**
	 * Renders an enabled module through its full pipeline.
	 *
	 * @param string $module_id Module identifier.
	 * @param array  $args      Per call settings.
	 * @param array  $context   Render context.
	 * @return string Empty string when the module is missing or disabled.
	 */
	public function render( $module_id, array $args = array(), array $context = array() ) {
		if ( ! $this->is_enabled( $module_id ) ) {
			return '';
		}

		$module = $this->get( $module_id );

		// The interface guarantees output(), so a module that implements it
		// directly rather than extending Abstract_Module renders like any
		// other rather than silently producing nothing.
		if ( ! $module instanceof Module ) {
			return '';
		}

		return $module->output( $args, $context );
	}

	/**
	 * Returns every module that emitted markup during this request.
	 *
	 * @return string[]
	 */
	public function rendered_ids() {
		$rendered = array();

		foreach ( $this->instances as $module_id => $module ) {
			// has_rendered() is not on the interface — read it the same way
			// has_content() is, so a third party module is asked rather than
			// assumed to have answered.
			if ( $module instanceof Module && method_exists( $module, 'has_rendered' ) && $module->has_rendered() ) {
				$rendered[] = $module_id;
			}
		}

		return $rendered;
	}
}
