<?php
/**
 * Admin dashboard page.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse\Admin;

use WPXero\MenuVerse\Capabilities;
use WPXero\MenuVerse\Modules\Module_Registry;
use WPXero\MenuVerse\Plugin;
use WPXero\MenuVerse\Settings\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Registers the single page React dashboard and its assets.
 */
final class Admin_Page {

	/**
	 * Admin page slug.
	 *
	 * @var string
	 */
	const SLUG = 'menuverse';

	/**
	 * Where the upgrade buttons point.
	 *
	 * @var string
	 */
	const PRICING_URL = 'https://wpxero.com/plugins/menuverse/pricing';

	/**
	 * Where the support link points.
	 *
	 * The wp.org forum rather than an address of ours: it is where somebody
	 * who found the plugin on wp.org already expects to ask, the thread stays
	 * readable by the next person with the same question, and it needs no
	 * inbox of our own to stay answered.
	 *
	 * @var string
	 */
	const SUPPORT_URL = 'https://wordpress.org/support/plugin/menuverse';

	/**
	 * Script handles that exist on this screen only for the panel editor.
	 *
	 * Dropped from the bundle's declared dependencies when no available
	 * module embeds the block editor. Between them these are around 880 KB
	 * gzipped — several times the dashboard's own bundle — and none of the
	 * rest of the dashboard touches them.
	 *
	 * `wp-components` is deliberately not on the list: the shell itself uses
	 * its SlotFill provider, so it is loaded either way.
	 *
	 * @var string[]
	 */
	const BLOCK_EDITOR_SCRIPTS = array(
		'wp-block-editor',
		'wp-block-library',
		'wp-blocks',
		'wp-core-data',
	);

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

	/**
	 * Module registry.
	 *
	 * @var Module_Registry
	 */
	private $modules;

	/**
	 * Hook suffix for the dashboard screen.
	 *
	 * @var string
	 */
	private $hook = '';

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

	/**
	 * Registers hooks.
	 *
	 * @return void
	 */
	public function boot() {
		add_action( 'admin_menu', array( $this, 'register_page' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
		add_filter( 'plugin_action_links_' . MENUVERSE_BASENAME, array( $this, 'action_links' ) );
	}

	/**
	 * Registers the top level admin page.
	 *
	 * @return void
	 */
	public function register_page() {
		$this->hook = add_menu_page(
			__( 'MenuVerse', 'menuverse' ),
			__( 'MenuVerse', 'menuverse' ),
			Capabilities::manage(),
			self::SLUG,
			array( $this, 'render_page' ),
			'dashicons-menu-alt',
			58.9
		);
	}

	/**
	 * Renders the mount point for the dashboard app.
	 *
	 * @return void
	 */
	public function render_page() {
		if ( ! Capabilities::current_user_can_manage() ) {
			wp_die( esc_html__( 'You are not allowed to manage MenuVerse settings.', 'menuverse' ), 403 );
		}

		printf(
			'<div class="wrap menuverse-admin"><div id="menuverse-admin-root"><noscript>%s</noscript></div></div>',
			esc_html__( 'MenuVerse needs JavaScript enabled to display its settings.', 'menuverse' )
		);
	}

	/**
	 * Adds a settings shortcut to the plugins list row.
	 *
	 * @param string[] $links Existing action links.
	 * @return string[]
	 */
	public function action_links( $links ) {
		if ( ! Capabilities::current_user_can_manage() ) {
			return $links;
		}

		array_unshift(
			$links,
			sprintf(
				'<a href="%s">%s</a>',
				esc_url( admin_url( 'admin.php?page=' . self::SLUG ) ),
				esc_html__( 'Settings', 'menuverse' )
			)
		);

		return $links;
	}

	/**
	 * Enqueues the dashboard bundle on its own screen only.
	 *
	 * @param string $hook_suffix Current admin screen hook.
	 * @return void
	 */
	public function enqueue( $hook_suffix ) {
		if ( $hook_suffix !== $this->hook || '' === $this->hook ) {
			return;
		}

		$this->drop_emoji_support();

		$assets = Plugin::instance()->assets();
		$script = $assets->url( 'admin.js' );

		if ( ! $script ) {
			add_action( 'admin_notices', array( $this, 'build_missing_notice' ) );
			return;
		}

		$meta = $assets->script_meta( 'admin' );

		/*
		 * Everything the block editor costs is decided here, once.
		 *
		 * The panel editor is the only screen that embeds it, and it is split
		 * into its own chunk that is fetched when someone opens a panel. The
		 * build still lists the editor's handles against the whole bundle,
		 * because dependency extraction reports what an entry point can reach
		 * rather than what a page view actually reaches — so a dashboard that
		 * cannot open a panel was downloading and parsing the entire block
		 * editor, the block library, the media modal and four `/wp/v2`
		 * preloads before it drew anything.
		 */
		$needs_editor = $this->modules->needs_block_editor();

		wp_enqueue_script(
			'menuverse-admin',
			$script,
			$needs_editor
				? $meta['dependencies']
				: array_values( array_diff( $meta['dependencies'], self::BLOCK_EDITOR_SCRIPTS ) ),
			$meta['version'],
			true
		);
		wp_set_script_translations( 'menuverse-admin', 'menuverse', MENUVERSE_PATH . 'languages' );

		$style = $assets->url( 'admin.css' );

		if ( $style ) {
			wp_enqueue_style(
				'menuverse-admin',
				$style,
				$needs_editor
					? array( 'wp-components', 'wp-block-editor', 'wp-block-library', 'wp-edit-blocks' )
					: array( 'wp-components' ),
				$assets->version( 'admin.css' )
			);
			wp_style_add_data( 'menuverse-admin', 'rtl', 'replace' );
		}

		if ( $needs_editor ) {
			// The panel editor embeds the block editor, which needs the media
			// modal and the editor's own preload data to behave like the real
			// one. Neither is any use to the rest of the dashboard: the media
			// modal drags in Backbone and prints its templates into the
			// footer, and the preloads run four REST requests inside the page
			// request — `/wp/v2/types` and `/wp/v2/taxonomies` at edit context
			// walk every registered type on the site.
			wp_enqueue_media();

			wp_add_inline_script(
				'wp-api-fetch',
				sprintf(
					'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
					wp_json_encode( $this->preload_paths() )
				),
				'after'
			);
		}

		wp_add_inline_script(
			'menuverse-admin',
			'window.menuverseAdmin = ' . wp_json_encode( $this->boot_data() ) . ';',
			'before'
		);
	}

	/**
	 * Takes WordPress's emoji support off this one screen.
	 *
	 * The emoji script scans the document for characters a browser cannot draw
	 * and swaps them for images. The dashboard is a React application whose
	 * text is our own translated strings, rendered after that scan has already
	 * run — there is nothing on the page for it to find, and it would be undone
	 * on the next render if there were. What it costs is a canvas feature test
	 * on every page view, an inline settings blob, a stylesheet and a script.
	 *
	 * Scoped to this screen deliberately: `enqueue()` has already returned for
	 * every other one, and these are global hooks. Turning emoji off across
	 * somebody's admin is not a decision a menu plugin gets to make.
	 *
	 * Both halves are needed. The detection script is still waiting on
	 * `admin_print_scripts`, so unhooking it is enough; the styles are
	 * enqueued from `admin_enqueue_scripts`, the hook we are on, and core
	 * registered its callback long before ours — so by now it has usually run
	 * already and there is a handle to dequeue rather than an action to
	 * remove. Both are done anyway, because "usually" depends on a priority
	 * neither side promises.
	 *
	 * @return void
	 */
	private function drop_emoji_support() {
		remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
		remove_action( 'admin_print_styles', 'print_emoji_styles' );
		remove_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' );

		wp_dequeue_style( 'wp-emoji-styles' );
	}

	/**
	 * Preloads the REST responses the dashboard needs on first paint.
	 *
	 * @return array
	 */
	private function preload_paths() {
		$paths = array(
			'/wp/v2/types?context=edit',
			'/wp/v2/types/menuverse_panel?context=edit',
			'/wp/v2/taxonomies?context=edit',
			'/wp/v2/themes?status=active',
		);

		// rest_preload_api_request folds one path at a time into a memo.
		return array_reduce( $paths, 'rest_preload_api_request', array() );
	}

	/**
	 * Prints a notice when the build output is missing.
	 *
	 * @return void
	 */
	public function build_missing_notice() {
		printf(
			'<div class="notice notice-error"><p>%s</p></div>',
			esc_html__( 'MenuVerse assets have not been built yet. Run "npm install && npm run build" in the plugin directory.', 'menuverse' )
		);
	}

	/**
	 * Returns where the upgrade buttons point.
	 *
	 * The public pricing page, not Freemius's in-admin pricing screen. The
	 * SDK's own URL carries checkout context, so the trade is deliberate: what
	 * is being sold is explained on the website, and somebody deciding whether
	 * to buy should read that page rather than land on a bare price table
	 * inside wp-admin. `menuverse_upgrade_url` puts the SDK's URL back for a
	 * site that wants the in-admin checkout:
	 *
	 *     add_filter( 'menuverse_upgrade_url', function () {
	 *         return menuverse_fs()->get_upgrade_url();
	 *     } );
	 *
	 * @return string
	 */
	private function upgrade_url() {
		/**
		 * Filters where every upgrade button in the dashboard points.
		 *
		 * @since 1.0.0
		 *
		 * @param string $url Upgrade URL.
		 */
		return esc_url_raw( apply_filters( 'menuverse_upgrade_url', self::PRICING_URL ) );
	}

	/**
	 * Returns where the support link points.
	 *
	 * Filterable for the same reason the upgrade URL is: a site running this
	 * from somewhere other than wp.org has somewhere else to send people.
	 *
	 * @return string
	 */
	private function support_url() {
		/**
		 * Filters where the dashboard's support link points.
		 *
		 * @since 1.0.0
		 *
		 * @param string $url Support URL.
		 */
		return esc_url_raw( apply_filters( 'menuverse_support_url', self::SUPPORT_URL ) );
	}

	/**
	 * Reports whether a free trial is actually on offer.
	 *
	 * @return bool
	 */
	private function has_trial() {
		if ( ! function_exists( 'menuverse_fs' ) || ! menuverse_fs() ) {
			return false;
		}

		$fs = menuverse_fs();

		return method_exists( $fs, 'is_trial_utilized' )
			&& method_exists( $fs, 'has_trial_plan' )
			&& $fs->has_trial_plan()
			&& ! $fs->is_trial_utilized();
	}

	/**
	 * Resolves where this site edits its menus.
	 *
	 * Classic themes get Appearance to Menus. A block theme does not: core
	 * only registers nav-menus.php when the theme declares `menus` support, so
	 * on a block theme that link opens a screen with no menu behind it. Those
	 * sites edit navigation in the site editor instead, and the wording has to
	 * follow the destination or the instruction names a screen that is not
	 * in the sidebar.
	 *
	 * @return array
	 */
	private function menu_editor() {
		$is_block_theme = function_exists( 'wp_is_block_theme' ) && wp_is_block_theme();
		$classic_menus  = current_theme_supports( 'menus' ) || current_theme_supports( 'nav-menus' );

		if ( $is_block_theme && ! $classic_menus ) {
			return array(
				'url'      => esc_url_raw( admin_url( 'site-editor.php?p=%2Fnavigation' ) ),
				'label'    => __( 'Open the navigation editor', 'menuverse' ),
				'location' => __( 'Appearance → Editor → Navigation', 'menuverse' ),
				'block'    => true,
			);
		}

		return array(
			'url'      => esc_url_raw( admin_url( 'nav-menus.php' ) ),
			'label'    => __( 'Create a menu', 'menuverse' ),
			'location' => __( 'Appearance → Menus', 'menuverse' ),
			'block'    => false,
		);
	}

	/**
	 * Builds the data handed to the dashboard on load.
	 *
	 * @return array
	 */
	private function boot_data() {
		$data = array(
			'version'      => MENUVERSE_VERSION,
			'restRoot'     => esc_url_raw( rest_url( 'menuverse/v1' ) ),
			'adminUrl'     => esc_url_raw( admin_url( 'admin.php?page=' . self::SLUG ) ),
			// The dashboard links out to core screens such as nav-menus.php,
			// which do not live at a hard coded /wp-admin/ on every install.
			'wpAdminUrl'   => esc_url_raw( admin_url() ),
			// Where a visitor edits their menus differs by theme, and sending
			// a block theme to nav-menus.php lands on a screen core did not
			// register — a dead end with no explanation. Resolved once here so
			// every screen that offers "create a menu" agrees.
			'menuEditor'   => $this->menu_editor(),
			'homeUrl'      => esc_url_raw( home_url( '/' ) ),
			'settings'     => $this->settings->all(),
			'modules'      => $this->modules->definitions(),
			'globalSchema' => $this->settings->global_schema(),
			'onboarded'    => (bool) $this->settings->get_state( 'onboarded', false ),
			'docsUrl'      => 'https://github.com/wpxero/menuverse/tree/main/docs',
			'supportUrl'   => $this->support_url(),
			// Drives the `ui.requires` hint, which explains why a setting that
			// depends on another plugin is unavailable rather than hiding it.
			'integrations' => Plugin::instance()->integrations()->status(),
			// Drives the `ui.pro` hint. Presentation only — what stops a gated
			// value taking effect is the server side gate on the render path.
			'isPro'        => \WPXero\MenuVerse\Pro::is_active(),
			// The kinds of user an item can be limited to. Read here rather
			// than fetched, because the list is fixed for the install and
			// three lines of JSON beats a round trip for it.
			'roles'        => $this->role_choices(),
			'upgradeUrl'   => $this->upgrade_url(),
			// Freemius knows whether a free trial is on offer. Saying "start a
			// free trial" when there is none is the kind of promise that costs
			// more than the sale it wins.
			'hasTrial'     => $this->has_trial(),
		);

		/**
		 * Filters the data bootstrapped into the dashboard.
		 *
		 * @since 1.0.0
		 *
		 * @param array $data Boot data.
		 */
		return apply_filters( 'menuverse_admin_boot_data', $data );
	}

	/**
	 * Lists the site's roles for the per item visibility control.
	 *
	 * Translated names rather than slugs: the slug is what gets stored and the
	 * name is what the site calls that kind of user, which is the half the
	 * person choosing recognises.
	 *
	 * @return array[] List of value and label pairs.
	 */
	private function role_choices() {
		$roles = array();

		foreach ( wp_roles()->roles as $slug => $role ) {
			$roles[] = array(
				'value' => $slug,
				'label' => translate_user_role( $role['name'] ),
			);
		}

		return $roles;
	}
}
