<?php
/**
 * Menu source registry.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Menus;

defined( 'ABSPATH' ) || exit;

/**
 * Holds the available menu source adapters and caches their trees per request.
 */
final class Menu_Source_Registry {

	/**
	 * Registered sources keyed by identifier.
	 *
	 * @var array<string,Menu_Source>
	 */
	private $sources = array();

	/**
	 * Request level tree cache.
	 *
	 * @var array<string,Menu_Tree|null>
	 */
	private $trees = array();

	/**
	 * Registers the built in sources and lets extensions add more.
	 *
	 * @return void
	 */
	public function boot() {
		$this->register( new Classic_Menu_Source() );
		$this->register( new Block_Menu_Source() );

		/**
		 * Fires so extensions can register additional menu sources.
		 *
		 * @since 1.0.0
		 *
		 * @param Menu_Source_Registry $registry Source registry.
		 */
		do_action( 'menuverse_register_menu_sources', $this );

		add_action( 'wp_update_nav_menu', array( $this, 'invalidate' ) );
		add_action( 'wp_update_nav_menu_item', array( $this, 'invalidate' ) );

		// Block navigation menus are posts, so they change through the post
		// hooks rather than the nav menu ones. Without these a tree cached for
		// a `wp_navigation` menu would outlive the edit that changed it.
		add_action( 'save_post_wp_navigation', array( $this, 'invalidate' ) );
		add_action( 'deleted_post', array( $this, 'invalidate_post' ), 10, 2 );
	}

	/**
	 * Registers a source adapter.
	 *
	 * @param Menu_Source $source Source adapter.
	 * @return bool
	 */
	public function register( Menu_Source $source ) {
		$id = $source->id();

		if ( ! is_string( $id ) || '' === $id || isset( $this->sources[ $id ] ) ) {
			return false;
		}

		$this->sources[ $id ] = $source;

		return true;
	}

	/**
	 * Returns a source adapter.
	 *
	 * @param string $source_id Source identifier.
	 * @return Menu_Source|null
	 */
	public function get( $source_id ) {
		return isset( $this->sources[ $source_id ] ) ? $this->sources[ $source_id ] : null;
	}

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

	/**
	 * Lists every menu from every source.
	 *
	 * @return array[]
	 */
	public function list_menus() {
		$menus = array();

		foreach ( $this->sources as $source ) {
			$menus = array_merge( $menus, $source->list_menus() );
		}

		/**
		 * Filters the combined list of available menus.
		 *
		 * @since 1.0.0
		 *
		 * @param array[] $menus Menu descriptors.
		 */
		return apply_filters( 'menuverse_available_menus', $menus );
	}

	/**
	 * Returns the normalized tree for a menu.
	 *
	 * @param string     $source_id Source identifier.
	 * @param int|string $menu_id   Menu identifier.
	 * @return Menu_Tree|null
	 */
	public function get_tree( $source_id, $menu_id ) {
		$key = $source_id . ':' . $menu_id;

		if ( array_key_exists( $key, $this->trees ) ) {
			return $this->trees[ $key ];
		}

		$source = $this->get( $source_id );
		$tree   = $source instanceof Menu_Source ? $source->get_tree( $menu_id ) : null;

		/**
		 * Filters a resolved menu tree.
		 *
		 * @since 1.0.0
		 *
		 * @param Menu_Tree|null $tree      Menu tree.
		 * @param string         $source_id Source identifier.
		 * @param int|string     $menu_id   Menu identifier.
		 */
		$tree = apply_filters( 'menuverse_menu_tree', $tree, $source_id, $menu_id );

		$this->trees[ $key ] = $tree;

		return $tree;
	}

	/**
	 * Clears cached trees for a menu.
	 *
	 * The request level cache is dropped wholesale, which is cheap and cannot
	 * be wrong. Anything a source persisted is cleared by that source: the
	 * registry has no business knowing how an adapter spells its cache key,
	 * and a third party source can keep one without touching this file.
	 *
	 * @param int|string $menu_id Menu identifier, zero to clear only the
	 *                            request level cache.
	 * @return void
	 */
	public function invalidate( $menu_id = 0 ) {
		$this->trees = array();

		if ( ! $menu_id ) {
			return;
		}

		foreach ( $this->sources as $source ) {
			// Not on the Menu_Source interface: a source with nothing
			// persisted has nothing to flush, and demanding the method would
			// break every source written against the released interface.
			if ( method_exists( $source, 'flush_cache' ) ) {
				$source->flush_cache( $menu_id );
			}
		}
	}

	/**
	 * Clears cached trees when a menu holding post is deleted.
	 *
	 * @param int           $post_id Deleted post ID.
	 * @param \WP_Post|null $post    Deleted post object.
	 * @return void
	 */
	public function invalidate_post( $post_id, $post = null ) {
		if ( $post instanceof \WP_Post && 'wp_navigation' !== $post->post_type ) {
			return;
		}

		$this->invalidate( $post_id );
	}
}
