<?php
/**
 * Show the dismissible admin notice
 *
 * @package Teydea_Studio\Careers\Dependencies\Universal_Modules
 */

namespace Teydea_Studio\Careers\Dependencies\Universal_Modules\Admin_Notice;

use Teydea_Studio\Careers\Dependencies\Utils;
use WP_Error;
use WP_REST_Response;
use WP_REST_Server;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // @codeCoverageIgnore
}

/**
 * The "Module_Admin_Notice" class
 *
 * Renders a dismissible WordPress admin notice and owns a REST endpoint that
 * persists the dismissal on the current user.
 *
 * Every customization point is a `protected get_*()` (or `is_*()`) method.
 * Subclasses override exactly the seams they need; the defaults implement
 * **boolean (one-shot) dismissal** — once a user clicks dismiss, the notice
 * never reappears.
 *
 * The seams group into three concerns:
 *
 * - **Identity / presentation:** {@see self::get_notice_id()},
 *   {@see self::get_notice_type()}, {@see self::get_asset_slug()},
 *   {@see self::get_endpoint_route()}, {@see self::get_render_hook()},
 *   {@see self::get_render_attributes()}, {@see self::get_nonce_action()},
 *   {@see self::get_user_meta_key()}.
 * - **Content / show-gating:** {@see self::get_message()} (required),
 *   {@see self::is_eligible_to_show()} for additional gating beyond the
 *   universal managing-permissions + not-dismissed checks.
 * - **Dismissal model:** {@see self::get_current_dismissal_token()},
 *   {@see self::is_dismissed_for_current_user()},
 *   {@see self::persist_dismissal()}. The base supports two models out of the
 *   box, selected by the *return type* of
 *   {@see self::get_current_dismissal_token()}: returning `true` (the default)
 *   gives **boolean one-shot** dismissal — once dismissed, the notice never
 *   reappears; returning a **string token** gives **token-keyed** dismissal —
 *   the dismissal is stored against that token and the notice re-surfaces
 *   whenever the token changes (e.g. a content hash of the underlying state,
 *   or an episode id). A token-keyed consumer overrides *only*
 *   {@see self::get_current_dismissal_token()} (to return the string token)
 *   and typically {@see self::get_user_meta_key()} (a distinct storage slot);
 *   it never reimplements the compare/persist bodies. A string token may read
 *   live state that shifts within a request, so the compare and persist paths
 *   read it through {@see self::resolve_dismissal_token()}, which memoizes it
 *   once per request — never call {@see self::get_current_dismissal_token()}
 *   directly from those paths.
 */
class Module_Admin_Notice extends Utils\Module {
	/**
	 * Shared managing-permissions gate and nonce-argument schema
	 */
	use Utils\With_REST_Helpers;

	/**
	 * Request-scoped cache for the resolved dismissal token
	 *
	 * @var bool|string
	 */
	private $dismissal_token;

	/**
	 * Whether {@see self::resolve_dismissal_token()} has memoized the token yet
	 *
	 * @var bool
	 */
	private bool $dismissal_token_resolved = false;

	/**
	 * Register hooks
	 *
	 * @return void
	 */
	public function register(): void {
		// Maybe register the admin notice on the chosen admin-notices surface.
		add_action( $this->get_render_hook(), [ $this, 'maybe_register_admin_notice' ] );

		// Maybe enqueue admin script used to dismiss the admin notice.
		add_action( 'admin_enqueue_scripts', [ $this, 'maybe_enqueue_script' ] );

		// Register the dismissal REST endpoint.
		add_action( 'rest_api_init', [ $this, 'register_endpoint' ] );
	}

	/**
	 * Notice body — required override
	 *
	 * @return string Notice message HTML.
	 */
	protected function get_message(): string {
		return '';
	}

	/**
	 * Notice id (short form; the data prefix is prepended automatically)
	 *
	 * Composed into `careers__{notice_id}` for the rendered notice's
	 * DOM `id` attribute. The dismissal JS handler's `noticeId` must use
	 * the full composed value.
	 *
	 * Short-form convention matches {@see self::get_user_meta_key()} and
	 * {@see self::get_nonce_action()} — return the suffix, the rendering
	 * layer / helpers prepend the data prefix.
	 *
	 * @return string Notice id (short form).
	 */
	protected function get_notice_id(): string {
		return 'admin_notice';
	}

	/**
	 * Notice type — one of `warning`, `error`, `success`, `info`
	 *
	 * @return string Notice type.
	 */
	protected function get_notice_type(): string {
		return 'warning';
	}

	/**
	 * Built asset slug used to enqueue the dismissal JS (resolved against `build/{slug}/`)
	 *
	 * Defaults to the notice id with underscores swapped for dashes — matches
	 * the conventional `build/{slug}/` naming. Override if your bundle lives
	 * at a different slug.
	 *
	 * @return string Asset slug.
	 */
	protected function get_asset_slug(): string {
		return str_replace( '_', '-', $this->get_notice_id() );
	}

	/**
	 * REST route (within the plugin's `{slug}/v1` namespace) for the dismissal endpoint
	 *
	 * @return string Route path with leading slash.
	 */
	protected function get_endpoint_route(): string {
		return '/admin-notice';
	}

	/**
	 * Admin-notices action to render on
	 *
	 * Defaults to `network_admin_notices` when the container is
	 * network-active (multisite + network-active plugin that opted in via
	 * `set_supports_network(true)`), otherwise `admin_notices`. Themes and
	 * non-network-aware plugins always fall back to `admin_notices` because
	 * {@see Utils\Container::is_network_enabled()} returns `false` by default.
	 *
	 * @return string Hook name.
	 */
	protected function get_render_hook(): string {
		return $this->container->is_network_enabled() ? 'network_admin_notices' : 'admin_notices';
	}

	/**
	 * Extra HTML attributes merged on top of the base `data-nonce` attribute
	 *
	 * Values are run through `esc_attr()` by `wp_admin_notice()` internally;
	 * pass raw strings.
	 *
	 * @return array<string,string> Attribute key => value map.
	 */
	protected function get_render_attributes(): array {
		return [];
	}

	/**
	 * Nonce action (short form; {@see Utils\Nonce::get_action()} prepends the data prefix)
	 *
	 * @return string Nonce action.
	 */
	protected function get_nonce_action(): string {
		return 'dismiss_notice';
	}

	/**
	 * User meta key (short form; {@see Utils\User::get_prefixed_meta_key()} prepends the data prefix)
	 *
	 * Defaults to `{notice_id}_dismissed`. Token-keyed subclasses typically
	 * override to a distinct slot so token storage doesn't collide with a
	 * boolean-dismissal slot.
	 *
	 * @return string Meta key.
	 */
	protected function get_user_meta_key(): string {
		return $this->get_notice_id() . '_dismissed';
	}

	/**
	 * Current dismissal token — persisted on dismissal, compared against on subsequent loads
	 *
	 * The return *type* selects the dismissal model that
	 * {@see self::is_dismissed_for_current_user()} and
	 * {@see self::persist_dismissal()} apply:
	 *
	 * - `true` (default) → boolean one-shot dismissal (stored once via `add_meta`).
	 * - a `string` token → token-keyed dismissal (overwritten via `update_meta`;
	 *   the notice re-surfaces whenever the token changes — e.g. a content hash
	 *   of the underlying state or an episode id).
	 *
	 * This is the raw seam — a string token may read live state (a content
	 * hash, a scan timestamp) that changes within a request. The compare and
	 * persist paths must read it through {@see self::resolve_dismissal_token()},
	 * never call this method directly.
	 *
	 * @return bool|string Token value.
	 */
	protected function get_current_dismissal_token() {
		return true;
	}

	/**
	 * Resolve the dismissal token once per request
	 *
	 * Wraps {@see self::get_current_dismissal_token()} with request-scoped
	 * memoization (a Module instance lives for exactly one request — page load
	 * or the REST dismissal). Both the persist write and the post-persist
	 * re-verification inside {@see self::dismiss()}, and the render + enqueue
	 * show-gates on page load, read through this method so they share a single
	 * snapshot of a token that may otherwise change mid-request. Without it, a
	 * token that shifts between the persist and the re-check makes a successful
	 * dismissal report a spurious failure, and a shift between the two
	 * show-gates can render the notice without enqueuing its dismissal script.
	 *
	 * @return bool|string Token value.
	 */
	protected function resolve_dismissal_token() {
		if ( ! $this->dismissal_token_resolved ) {
			$this->dismissal_token          = $this->get_current_dismissal_token();
			$this->dismissal_token_resolved = true;
		}

		return $this->dismissal_token;
	}

	/**
	 * Whether the current user has dismissed this notice for the current state
	 *
	 * Branches on the dismissal model selected by
	 * {@see self::get_current_dismissal_token()}: a boolean token treats any
	 * truthy stored value as "permanently dismissed" (one-shot); a string
	 * token compares the stored token against the current one (an empty stored
	 * value means "never dismissed"), so the notice re-surfaces whenever the
	 * token changes.
	 *
	 * @return bool True when the notice should stay hidden for this user.
	 */
	protected function is_dismissed_for_current_user(): bool {
		$user  = new Utils\User( $this->container );
		$token = $this->resolve_dismissal_token();

		if ( is_bool( $token ) ) {
			return $user->get_meta_as_boolean( $this->get_user_meta_key(), false );
		}

		$stored_token  = Utils\Type::ensure_string( $user->get_meta( $this->get_user_meta_key(), false ) );
		$current_token = Utils\Type::ensure_string( $token );

		return '' !== $stored_token && $stored_token === $current_token;
	}

	/**
	 * Persist the dismissal token for the current user
	 *
	 * Boolean (one-shot) dismissal stores a single row via `add_meta`;
	 * token-keyed dismissal overwrites the single slot via `update_meta` so
	 * the stored token always reflects the most recently dismissed state. The
	 * model is selected by {@see self::get_current_dismissal_token()}'s return
	 * type.
	 *
	 * @return int|bool Meta ID on first write, `true` on update, `false` on failure.
	 */
	protected function persist_dismissal() {
		$user  = new Utils\User( $this->container );
		$token = $this->resolve_dismissal_token();

		if ( is_bool( $token ) ) {
			return $user->add_meta( $this->get_user_meta_key(), $token, false );
		}

		return $user->update_meta( $this->get_user_meta_key(), $token, false );
	}

	/**
	 * Additional show-gates beyond the universal managing-permissions + not-dismissed checks
	 *
	 * Override to skip the notice on specific screens, when there is no
	 * underlying state to surface, etc. Default: always eligible.
	 *
	 * @return bool True when the notice is eligible to render at all.
	 */
	protected function is_eligible_to_show(): bool {
		return true;
	}

	/**
	 * Whether the admin notice should be shown right now
	 *
	 * @return bool True when the notice should render.
	 */
	protected function should_show_admin_notice(): bool {
		$user = new Utils\User( $this->container );

		if ( ! $user->has_managing_permissions() ) {
			return false;
		}

		if ( ! $this->is_eligible_to_show() ) {
			return false;
		}

		return ! $this->is_dismissed_for_current_user();
	}

	/**
	 * Maybe register the admin notice
	 *
	 * @return void
	 */
	public function maybe_register_admin_notice(): void {
		if ( ! $this->should_show_admin_notice() ) {
			return;
		}

		$nonce = new Utils\Nonce( $this->container, $this->get_nonce_action() );

		/**
		 * Build the notice wrapper's attribute payload
		 *
		 * `wp_admin_notice()` runs every entry of its `attributes` array
		 * through `esc_attr()` internally before printing them into the
		 * wrapper `<div …>` tag. Passing raw nonce + raw subclass-provided
		 * attribute values here is therefore safe; subclasses do not need
		 * to pre-escape. If a future WordPress release changes that
		 * contract, every notice that relies on it needs to switch to
		 * `esc_attr()` at the call site.
		 *
		 * A live-region role is set by default so assistive tech announces a
		 * problem-state notice when it first renders: the assertive `role="alert"`
		 * for `error`-type notices, and the politer `role="status"` for
		 * `warning`-type notices (which convey an actionable problem but are less
		 * urgent than an error). Informational notices get none. Subclasses can
		 * override the role (or remove it) via {@see self::get_render_attributes()}.
		 */
		$base_attributes = [ 'data-nonce' => $nonce->create() ];

		$notice_type = $this->get_notice_type();

		if ( 'error' === $notice_type ) {
			$base_attributes['role'] = 'alert';
		} elseif ( 'warning' === $notice_type ) {
			$base_attributes['role'] = 'status';
		}

		$attributes = array_merge(
			$base_attributes,
			$this->get_render_attributes(),
		);

		wp_admin_notice(
			wp_kses(
				$this->get_message(),
				[
					'a'      => [ 'href' => true ],
					'br'     => [],
					'p'      => [],
					'strong' => [],
				],
			),
			[
				'id'          => sprintf( '%s__%s', $this->container->get_data_prefix(), $this->get_notice_id() ),
				'attributes'  => $attributes,
				'type'        => $this->get_notice_type(),
				'dismissible' => true,
			],
		);
	}

	/**
	 * Enqueue admin script used to dismiss the admin notice
	 *
	 * @return void
	 */
	public function maybe_enqueue_script(): void {
		if ( ! $this->should_show_admin_notice() ) {
			return;
		}

		$asset = new Utils\Asset( $this->container, $this->get_asset_slug() );
		$asset->enqueue_script( false );
	}

	/**
	 * Register endpoint
	 *
	 * @return void
	 */
	public function register_endpoint(): void {
		register_rest_route(
			sprintf( '%s/v1', $this->container->get_slug() ),
			$this->get_endpoint_route(),
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ $this, 'dismiss' ],
				'args'                => [
					'nonce' => $this->get_nonce_arg( $this->get_nonce_action() ),
				],
				'permission_callback' => $this->get_managing_permissions_callback(
					__( 'You do not have permission to dismiss this notice.', 'careers' ),
				),
			],
		);
	}

	/**
	 * Dismiss the admin notice
	 *
	 * The `false` return from {@see self::persist_dismissal()} is ambiguous —
	 * `add_user_meta()` (boolean one-shot model) returns `false` when a unique
	 * meta row already exists, and `update_user_meta()` (token-keyed model)
	 * returns `false` when the new value equals the stored value. Both are
	 * "already dismissed for this state" successes, not failures. Re-verify via
	 * {@see self::is_dismissed_for_current_user()} so stale-page double-clicks
	 * and parallel-tab dismissals don't bounce the JS handler into a retry
	 * loop.
	 *
	 * The persist write and this re-check both read the token through
	 * {@see self::resolve_dismissal_token()}, so they compare against one
	 * snapshot. A string token that reads live state can change between the two
	 * calls; sharing the snapshot keeps a genuinely-successful dismissal from
	 * reporting a spurious failure.
	 *
	 * @return WP_Error|WP_REST_Response Instance of WP_REST_Response on success, instance of WP_Error on failure.
	 */
	public function dismiss() {
		$saved = $this->persist_dismissal();

		if ( false === $saved && ! $this->is_dismissed_for_current_user() ) {
			return new WP_Error(
				'unable_to_persist_dismissal',
				__( 'Unable to persist the notice dismissal.', 'careers' ),
			);
		}

		return new WP_REST_Response( [], 200 );
	}
}
