<?php
/**
 * File Path: includes/admin/class-mbs-editor-registry.php
 *
 * Book Editor Registry
 *
 * Singleton. Owns the ordered domain config list and dispatches render and
 * save calls to handler classes. Handler instances are lazy-instantiated and
 * cached by class name, so a class serving multiple sections (e.g.
 * MBS_Metabox_Book_Header, which handles 3 sections across 2 domains) is
 * never constructed more than once.
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Admin
 * @since      1.2.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class MBS_Editor_Registry { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/** @var self|null Singleton instance. */
	private static ?self $instance = null;

	/** @var array Ordered list of registered domain config arrays. */
	private array $domains = array();

	/**
	 * Handler instances keyed by class name string. Class-keyed (not domain/section-keyed)
	 * so that handlers serving multiple sections are reused, not re-instantiated.
	 *
	 * @var array<string, object>
	 */
	private array $handlers = array();

	/**
	 * Private constructor. Use get_instance().
	 */
	private function __construct() {}

	/**
	 * Return or create the singleton instance.
	 *
	 * @return self
	 * @since 1.2.0
	 */
	public static function get_instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Register a domain with its full config array, including all sections.
	 *
	 * Appends to the ordered domain list. Throws if the domain key already exists
	 * (duplicate domain keys produce two tabs with identical data-mbs-domain values,
	 * making JS activation targeting ambiguous — a programming error, not a runtime case).
	 *
	 * @param array $config Domain config. Required keys:
	 *                      'key'      (string) — unique domain identifier.
	 *                      'label'    (string) — tab label.
	 *                      'sections' (array)  — ordered section config arrays.
	 * @return void
	 * @throws RuntimeException If the domain key is already registered.
	 * @since 1.2.0
	 */
	public function register_domain( array $config ): void {
		foreach ( $this->domains as $existing ) {
			if ( $existing['key'] === $config['key'] ) {
				throw new RuntimeException(
					'MBS_Editor_Registry: duplicate domain key "' . $config['key'] . '".', // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is internal programmer error, never rendered as HTML
				);
			}
		}
		$this->domains[] = $config;
	}

	/**
	 * Append a single section to an already-registered domain.
	 *
	 * Used for conditional sections (e.g. the WooCommerce tab appended after the
	 * base core-book-info domain is registered). Throws if the domain key is not
	 * found (missing domain at boot = programming error) or if the section key
	 * already exists within the target domain (duplicate section key causes
	 * unpredictable render and routing behavior).
	 *
	 * @param string $domain_key Domain to append the section to.
	 * @param array  $section    Section config. Required keys:
	 *                           'key'     (string) — unique within the domain.
	 *                           'label'   (string) — sub-nav label.
	 *                           'handler' (string) — handler class name string.
	 * @return void
	 * @throws RuntimeException If $domain_key is not found or section key duplicates.
	 * @since 1.2.0
	 */
	public function register_section( string $domain_key, array $section ): void {
		foreach ( $this->domains as &$domain ) {
			if ( $domain['key'] !== $domain_key ) {
				continue;
			}
			foreach ( $domain['sections'] as $existing ) {
				if ( $existing['key'] === $section['key'] ) {
					throw new RuntimeException(
						'MBS_Editor_Registry: duplicate section key "' . $section['key'] . '" in domain "' . $domain_key . '".', // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is internal programmer error, never rendered as HTML
					);
				}
			}
			$domain['sections'][] = $section;
			return;
		}
		unset( $domain );
		throw new RuntimeException(
			'MBS_Editor_Registry: domain key "' . $domain_key . '" not found. Call register_domain() first.', // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message is internal programmer error, never rendered as HTML
		);
	}

	/**
	 * Return the full ordered array of registered domain configs.
	 *
	 * @return array
	 * @since 1.2.0
	 */
	public function get_domains(): array {
		return $this->domains;
	}

	/**
	 * Render the content for one domain/section panel.
	 *
	 * Lazy-instantiates the handler by class name on first use, caches it, then
	 * calls $handler->render_section( $section_key, $post ). The method name is
	 * render_section(), not render_active_section() — all panels are rendered into
	 * the DOM on every page load; JS controls visibility.
	 *
	 * @param string  $domain_key  Domain key.
	 * @param string  $section_key Section key.
	 * @param WP_Post $post        The post being edited.
	 * @return void
	 * @since 1.2.0
	 */
	public function render_section( string $domain_key, string $section_key, WP_Post $post ): void {
		$handler = $this->get_handler_for( $domain_key, $section_key );
		if ( null !== $handler ) {
			$handler->render_section( $section_key, $post );
		}
	}

	/**
	 * Dispatch save to every registered handler for every registered section.
	 *
	 * Iterates all domains and sections in registration order; calls
	 * $handler->save_section( $section_key, $post_id, $post ) for each.
	 * A handler serving multiple sections is called once per section — routing
	 * by $section_key is the handler's responsibility.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post    The post being saved.
	 * @return void
	 * @since 1.2.0
	 */
	public function save_all( int $post_id, WP_Post $post ): void {
		foreach ( $this->domains as $domain ) {
			foreach ( $domain['sections'] as $section ) {
				$handler = $this->get_handler_for( $domain['key'], $section['key'] );
				if ( null !== $handler ) {
					$handler->save_section( $section['key'], $post_id, $post );
				}
			}
		}
	}

	/**
	 * Resolve the handler instance for a given domain/section pair.
	 *
	 * Lazy-instantiates the handler class on first use. Cache key is the class
	 * name string (not the domain+section pair), ensuring a class that serves
	 * multiple sections is instantiated exactly once.
	 *
	 * @param string $domain_key  Domain key.
	 * @param string $section_key Section key.
	 * @return object|null        Handler instance, or null if the pair is not registered.
	 * @since 1.2.0
	 */
	private function get_handler_for( string $domain_key, string $section_key ): ?object {
		foreach ( $this->domains as $domain ) {
			if ( $domain['key'] !== $domain_key ) {
				continue;
			}
			foreach ( $domain['sections'] as $section ) {
				if ( $section['key'] !== $section_key ) {
					continue;
				}
				$handler_class = $section['handler'];
				if ( ! isset( $this->handlers[ $handler_class ] ) ) {
					$this->handlers[ $handler_class ] = new $handler_class();
				}
				return $this->handlers[ $handler_class ];
			}
		}
		return null;
	}
}
