<?php
/**
 * Admin-page definition value object.
 *
 * Immutable Phase-8 slice 8c-1 value object that encapsulates one
 * WordPress admin page's registration metadata for forwarding to
 * `add_menu_page()` / `add_submenu_page()` through the
 * {@see \Affilun\Presentation\Admin\AdminMenuRegistrar}.
 *
 * The class is deliberately a plain immutable VO with no behavior
 * beyond validation and a small set of accessors: it exists so the
 * {@see AdminMenuRegistrar} can be driven from data rather than
 * imperative `add_menu_page()` / `add_submenu_page()` calls scattered
 * across the codebase, mirroring the registrar/definition split used
 * by the Phase-7 block surface
 * ({@see \Affilun\Presentation\Block\BlockDefinition})
 * and the Phase-6 REST surface
 * ({@see \Affilun\Presentation\Rest\Routing\RouteDefinition}).
 *
 * Field semantics:
 *
 *   - `parentSlug` is the WordPress parent-menu slug. The empty
 *     string indicates a top-level menu (registered via
 *     `add_menu_page()`); a non-empty value (e.g.
 *     `'affilun'` for the plugin's own top-level menu,
 *     or a core slug like `'options-general.php'`) indicates a
 *     submenu (registered via `add_submenu_page()`).
 *
 *   - `pageTitle` / `menuTitle` / `capability` are the canonical
 *     WordPress arguments. All three are validated non-empty after
 *     `trim()`; the capability literal itself is opaque at this
 *     layer (the §3.1 admin-ui-spec capability set is encoded by
 *     {@see AdminCapability}).
 *
 *   - `menuSlug` is the URL-bound slug for the page. Phase-8
 *     enforces a `affilun-` prefix to avoid collisions with WordPress
 *     core / other plugins; the rest of the slug must match the
 *     `^[a-z][a-z0-9-]*$` grammar that WordPress's own admin
 *     routing assumes.
 *
 *   - `iconUrl` is forwarded to `add_menu_page()` for top-level
 *     menus only; submenu registrations ignore it. The empty string
 *     is permitted and tells WordPress to use the default icon.
 *
 *   - `position` is the integer menu position. `0` means "unset"
 *     (default WordPress placement); negative values are rejected.
 *
 *   - `renderCallback` is the `function(): void` invoked by
 *     WordPress when the page is rendered. It MUST echo the page
 *     HTML directly; it MUST NOT return a string.
 *
 *   - `loadCallback` is the optional `function(): void` invoked on
 *     the `load-{$hookSuffix}` action when the page loads. `null`
 *     when no load-time hook is needed.
 *
 * Validation failures raise {@see InvalidArgumentException}; the
 * messages are developer-facing and never rendered to HTML.
 *
 * @package Affilun\Presentation\Admin
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin;

use InvalidArgumentException;

/**
 * Immutable value object describing one admin-page registration.
 *
 * See the file-level docblock for the field semantics and the
 * `affilun-` menu-slug prefix rule that prevents collisions with
 * WordPress core / other plugins.
 */
final class AdminPageDefinition {

	/**
	 * Phase-8 menu-slug grammar: lowercase ASCII + hyphen, starting
	 * with a letter. The `affilun-` prefix is enforced separately so
	 * the grammar check and the prefix check produce distinct
	 * developer-facing exception messages.
	 */
	private const MENU_SLUG_PATTERN = '/^[a-z][a-z0-9-]*$/';

	/**
	 * Phase-8 menu-slug prefix. Every plugin-registered admin page
	 * MUST carry this prefix so a sniff over `menuSlug()` values
	 * can reliably distinguish plugin pages from core / third-party
	 * pages.
	 */
	private const MENU_SLUG_PREFIX = 'affilun-';

	/**
	 * Sentinel parent slug for pages that should be routable but hidden from
	 * visible admin menus. The registrar forwards this to add_submenu_page(null).
	 */
	public const HIDDEN_PARENT_SLUG = '__affilun_hidden__';

	/**
	 * WordPress parent-menu slug; empty string for top-level menus.
	 */
	private string $parentSlug;

	/**
	 * `add_menu_page()` / `add_submenu_page()` page-title argument.
	 */
	private string $pageTitle;

	/**
	 * `add_menu_page()` / `add_submenu_page()` menu-title argument.
	 */
	private string $menuTitle;

	/**
	 * Required capability literal (opaque at this layer).
	 */
	private string $capability;

	/**
	 * URL-bound menu slug.
	 */
	private string $menuSlug;

	/**
	 * Top-level menu icon URL (ignored for submenus).
	 */
	private string $iconUrl;

	/**
	 * Menu position; `0` means "unset".
	 */
	private int $position;

	/**
	 * Page-render callable. Signature: `function(): void`.
	 *
	 * @var callable
	 */
	private $renderCallback;

	/**
	 * Optional page-load callable. Signature: `function(): void`.
	 *
	 * @var callable|null
	 */
	private $loadCallback;

	/**
	 * Construct an immutable admin-page definition.
	 *
	 * @param string        $parentSlug     WordPress parent-menu slug; empty string for top-level menus.
	 * @param string        $pageTitle      `add_menu_page()` / `add_submenu_page()` page-title argument.
	 * @param string        $menuTitle      `add_menu_page()` / `add_submenu_page()` menu-title argument.
	 * @param string        $capability     Required capability literal.
	 * @param string        $menuSlug       URL-bound menu slug; must match `^[a-z][a-z0-9-]*$` and start with `affilun-`.
	 * @param callable      $renderCallback Page-render callable.
	 * @param string        $iconUrl        Top-level menu icon URL (ignored for submenus); empty string permitted.
	 * @param int           $position       Menu position; `0` means "unset"; negative values rejected.
	 * @param callable|null $loadCallback   Optional page-load callable; null when no load-time hook is needed.
	 *
	 * @phpstan-param callable(): void      $renderCallback
	 * @phpstan-param (callable(): void)|null $loadCallback
	 *
	 * @throws InvalidArgumentException When `$pageTitle`, `$menuTitle`, `$capability`, or `$menuSlug`
	 *                                  is empty after trimming, when `$menuSlug` violates the grammar
	 *                                  or the `affilun-` prefix rule, or when `$position` is negative.
	 */
	public function __construct(
		string $parentSlug,
		string $pageTitle,
		string $menuTitle,
		string $capability,
		string $menuSlug,
		callable $renderCallback,
		string $iconUrl = '',
		int $position = 0,
		?callable $loadCallback = null
	) {
		if ( trim( $pageTitle ) === '' ) {
			$message = 'AdminPageDefinition pageTitle must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( trim( $menuTitle ) === '' ) {
			$message = 'AdminPageDefinition menuTitle must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( trim( $capability ) === '' ) {
			$message = 'AdminPageDefinition capability must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( trim( $menuSlug ) === '' ) {
			$message = 'AdminPageDefinition menuSlug must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( preg_match( self::MENU_SLUG_PATTERN, $menuSlug ) !== 1 ) {
			$message = sprintf(
				'AdminPageDefinition menuSlug must match ^[a-z][a-z0-9-]*$; got "%s".',
				$menuSlug
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( strncmp( $menuSlug, self::MENU_SLUG_PREFIX, strlen( self::MENU_SLUG_PREFIX ) ) !== 0 ) {
			$message = sprintf(
				'AdminPageDefinition menuSlug must start with the "%s" prefix; got "%s".',
				self::MENU_SLUG_PREFIX,
				$menuSlug
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $position < 0 ) {
			$message = sprintf(
				'AdminPageDefinition position must be >= 0; got %d.',
				$position
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->parentSlug     = $parentSlug;
		$this->pageTitle      = $pageTitle;
		$this->menuTitle      = $menuTitle;
		$this->capability     = $capability;
		$this->menuSlug       = $menuSlug;
		$this->renderCallback = $renderCallback;
		$this->iconUrl        = $iconUrl;
		$this->position       = $position;
		$this->loadCallback   = $loadCallback;
	}

	/**
	 * Return the WordPress parent-menu slug; empty string for
	 * top-level menus.
	 */
	public function parentSlug(): string {
		return $this->parentSlug;
	}

	/**
	 * Return the page-title argument forwarded to
	 * `add_menu_page()` / `add_submenu_page()`.
	 */
	public function pageTitle(): string {
		return $this->pageTitle;
	}

	/**
	 * Return the menu-title argument forwarded to
	 * `add_menu_page()` / `add_submenu_page()`.
	 */
	public function menuTitle(): string {
		return $this->menuTitle;
	}

	/**
	 * Return the required capability literal.
	 */
	public function capability(): string {
		return $this->capability;
	}

	/**
	 * Return the URL-bound menu slug.
	 */
	public function menuSlug(): string {
		return $this->menuSlug;
	}

	/**
	 * Return the page-render callable.
	 *
	 * @return callable
	 *
	 * @phpstan-return callable(): void
	 */
	public function renderCallback(): callable {
		return $this->renderCallback;
	}

	/**
	 * Return the top-level menu icon URL (ignored for submenus).
	 */
	public function iconUrl(): string {
		return $this->iconUrl;
	}

	/**
	 * Return the menu position; `0` means "unset".
	 */
	public function position(): int {
		return $this->position;
	}

	/**
	 * Return the optional page-load callable; null when no
	 * load-time hook is needed.
	 *
	 * @return callable|null
	 *
	 * @phpstan-return (callable(): void)|null
	 */
	public function loadCallback(): ?callable {
		return $this->loadCallback;
	}

	/**
	 * Report whether this definition describes a top-level menu
	 * (i.e. `parentSlug` is the empty string).
	 */
	public function isTopLevel(): bool {
		return $this->parentSlug === '';
	}

	/**
	 * Report whether `$other` describes the same admin-page
	 * registration as this definition.
	 *
	 * Equality is structural over the string and integer fields;
	 * the `renderCallback` / `loadCallback` callables are compared
	 * by reference identity (`===`), which matches the registrar's
	 * forwarding semantics.
	 *
	 * @param self $other Definition to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->parentSlug === $other->parentSlug
			&& $this->pageTitle === $other->pageTitle
			&& $this->menuTitle === $other->menuTitle
			&& $this->capability === $other->capability
			&& $this->menuSlug === $other->menuSlug
			&& $this->iconUrl === $other->iconUrl
			&& $this->position === $other->position
			&& $this->renderCallback === $other->renderCallback
			&& $this->loadCallback === $other->loadCallback;
	}
}
