<?php
/**
 * BSF Admin Notices
 *
 * An easy to use PHP Library to add dismissible admin notices in the WordPress admin.
 *
 * @package BSF Admin Notices
 * @since   1.2.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

if ( ! class_exists( 'BSF_Admin_Notices' ) ) :

	/**
	 * BSF_Admin_Notices
	 *
	 * Renamed from Astra_Notices. All runtime strings (AJAX action, nonce,
	 * script handles, JS globals, CSS classes, option keys, ID prefixes) are
	 * intentionally frozen at their original values so old plugin JS/CSS that
	 * is already shipped continues to work without updates.
	 *
	 * @since 1.2.0
	 */
	class BSF_Admin_Notices {

		/**
		 * Library version.
		 *
		 * @access private
		 * @var string
		 * @since 1.2.0
		 */
		private static $version = '1.2.1';

		/**
		 * Registered notices.
		 *
		 * @access private
		 * @var array
		 * @since 1.2.0
		 */
		private static $notices = array();

		/**
		 * Instance.
		 *
		 * @access private
		 * @var object Class object.
		 * @since 1.2.0
		 */
		private static $instance;

		/**
		 * Initiator.
		 *
		 * @since 1.2.0
		 * @return object initialized object of class.
		 */
		public static function get_instance() {
			if ( ! isset( self::$instance ) ) {
				self::$instance = new self();
			}
			return self::$instance;
		}

		/**
		 * Constructor.
		 *
		 * @since 1.2.0
		 */
		public function __construct() {
			add_action( 'admin_notices', array( $this, 'show_notices' ), 30 );
			add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
			add_action( 'wp_ajax_astra-notice-dismiss', array( $this, 'dismiss_notice' ) );
			add_filter( 'wp_kses_allowed_html', array( $this, 'add_data_attributes' ), 10, 2 );
		}

		/**
		 * Filters and returns a list of allowed tags and attributes for a given context.
		 *
		 * @param array  $allowedposttags array of allowed tags.
		 * @param string $context Context type (explicit).
		 * @since 1.2.0
		 * @return array
		 */
		public function add_data_attributes( $allowedposttags, $context ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
			$allowedposttags['a']['data-repeat-notice-after'] = true;

			return $allowedposttags;
		}

		/**
		 * Add Notice.
		 *
		 * @since 1.2.0
		 * @param array $args Notice arguments.
		 * @return void
		 */
		public static function add_notice( $args = array() ) {
			self::$notices[] = $args;

			if ( ! isset( $args['id'] ) ) {
				return;
			}

			$notice_id = sanitize_key( $args['id'] ); // Notice ID.
			$notices   = get_option( 'allowed_astra_notices', array() );
			if ( ! in_array( $notice_id, $notices, true ) ) {
				$notices[] = $notice_id; // Add notice id to the array.
				update_option( 'allowed_astra_notices', $notices ); // Update the option.
			}
		}

		/**
		 * Dismiss Notice.
		 *
		 * @since 1.2.0
		 * @return void
		 */
		public function dismiss_notice() {
			check_ajax_referer( 'astra-notices', 'nonce' );

			$notice_id           = ( isset( $_POST['notice_id'] ) ) ? sanitize_key( wp_unslash( $_POST['notice_id'] ) ) : '';
			$repeat_notice_after = ( isset( $_POST['repeat_notice_after'] ) ) ? absint( $_POST['repeat_notice_after'] ) : 0;
			$notice              = $this->get_notice_by_id( $notice_id );
			$capability          = isset( $notice['capability'] ) ? $notice['capability'] : 'manage_options';

			$has_cap = current_user_can( $capability );

			/**
			 * Filters whether the current user passes the capability check for notice dismissal.
			 *
			 * Both the legacy and new filter names are fired for backward compatibility.
			 * Filters can only restrict access (return false), never grant it — if the
			 * underlying current_user_can() check fails, filters cannot override to true.
			 */
			$cap_check = apply_filters( 'astra_notices_user_cap_check', $has_cap );
			$cap_check = apply_filters( 'bsf_admin_notices_user_cap_check', $cap_check );

			if ( ! $has_cap || ! $cap_check ) {
				wp_send_json_error( esc_html__( 'Permission denied.', 'astra-sites' ) );
			}

			$allowed_notices = get_option( 'allowed_astra_notices', array() ); // Get allowed notices.

			// Define restricted user meta keys using the dynamic table prefix.
			global $wpdb;
			$wp_default_meta_keys = array(
				$wpdb->prefix . 'capabilities',
				$wpdb->prefix . 'user_level',
				$wpdb->prefix . 'user-settings',
				'account_status',
				'session_tokens',
			);

			// if $notice_id does not start with astra-notices-id and notice_id is not from the allowed notices, then return.
			if ( 0 !== strpos( $notice_id, 'astra-notices-id-' ) && ( ! in_array( $notice_id, $allowed_notices, true ) ) ) {
				wp_send_json_error( esc_html__( 'Invalid notice ID.', 'astra-sites' ) );
			}

			// Valid inputs?
			if ( ! empty( $notice_id ) ) {

				if ( in_array( $notice_id, $wp_default_meta_keys, true ) ) {
					wp_send_json_error( esc_html__( 'Invalid notice ID.', 'astra-sites' ) );
				}

				if ( ! empty( $repeat_notice_after ) ) {
					set_transient( $notice_id, true, $repeat_notice_after );
				} else {
					update_user_meta( get_current_user_id(), $notice_id, 'notice-dismissed' );
				}

				wp_send_json_success();
			}

			wp_send_json_error();
		}

		/**
		 * Enqueue Scripts.
		 *
		 * @since 1.2.0
		 * @return void
		 */
		public function enqueue_scripts() {
			wp_register_style( 'astra-notices', self::get_url() . 'notices.css', array(), self::$version );
			wp_register_script( 'astra-notices', self::get_url() . 'notices.js', array( 'jquery' ), self::$version, true );
			wp_localize_script(
				'astra-notices',
				'astraNotices',
				array(
					'_notice_nonce' => wp_create_nonce( 'astra-notices' ),
				)
			);
		}

		/**
		 * Sort the notices based on the given priority of the notice.
		 * This function is called from usort()
		 *
		 * @since 1.2.0
		 * @param array $notice_1 First notice.
		 * @param array $notice_2 Second Notice.
		 * @return array
		 */
		public function sort_notices( $notice_1, $notice_2 ) {
			if ( ! isset( $notice_1['priority'] ) ) {
				$notice_1['priority'] = 10;
			}
			if ( ! isset( $notice_2['priority'] ) ) {
				$notice_2['priority'] = 10;
			}

			return $notice_1['priority'] - $notice_2['priority'];
		}

		/**
		 * Get all registered notices.
		 *
		 * @return array|null
		 */
		private function get_notices() {
			usort( self::$notices, array( $this, 'sort_notices' ) );

			return self::$notices;
		}

		/**
		 * Get notice by notice_id.
		 *
		 * @param string $notice_id Notice id.
		 *
		 * @return array notice based on the notice id.
		 */
		private function get_notice_by_id( $notice_id ) {
			if ( empty( $notice_id ) ) {
				return array();
			}

			$notices = $this->get_notices();
			$notice  = wp_list_filter(
				$notices,
				array(
					'id' => $notice_id,
				)
			);

			return ( ! empty( $notice ) && isset( $notice[0] ) ) ? $notice[0] : array();
		}

		/**
		 * Display the notices in the WordPress admin.
		 *
		 * @since 1.2.0
		 * @return void
		 */
		public function show_notices() {
			$defaults = array(
				'id'                         => '',      // Optional, Notice ID. If empty it set `astra-notices-id-<$array-index>`.
				'type'                       => 'info',  // Optional, Notice type. Default `info`. Expected [info, warning, notice, error].
				'message'                    => '',      // Optional, Message.
				'show_if'                    => true,    // Optional, Show notice on custom condition. E.g. 'show_if' => if( is_admin() ) ? true, false, .
				'repeat-notice-after'        => '',      // Optional, Dismiss-able notice time. It'll auto show after given time.
				'display-notice-after'       => false,      // Optional, Dismiss-able notice time. It'll auto show after given time.
				'class'                      => '',      // Optional, Additional notice wrapper class.
				'priority'                   => 10,      // Priority of the notice.
				'display-with-other-notices' => true,    // Should the notice be displayed if other notices  are being displayed from BSF_Admin_Notices.
				'is_dismissible'             => true,
				'capability'                 => 'manage_options', // User capability - This capability is required for the current user to see this notice.
			);

			// Count for the notices that are rendered.
			$notices_displayed = 0;
			$notices           = $this->get_notices();

			foreach ( $notices as $key => $notice ) {
				$notice = wp_parse_args( $notice, $defaults );

				// Show notices only for users with `manage_options` cap.
				if ( ! current_user_can( $notice['capability'] ) ) {
					continue;
				}

				$notice['id']      = self::get_notice_id( $notice, $key );
				$notice['classes'] = self::get_wrap_classes( $notice );

				// Notices visible after transient expire.
				if ( isset( $notice['show_if'] ) && true === $notice['show_if'] ) {

					// don't display the notice if it is not supposed to be displayed with other notices.
					if ( 0 !== $notices_displayed && false === $notice['display-with-other-notices'] ) {
						continue;
					}

					if ( self::is_expired( $notice ) ) {

						self::markup( $notice );
						++$notices_displayed;
					}
				}
			}
		}

		/**
		 * Render a notice.
		 *
		 * @since 1.2.0
		 * @param  array $notice Notice markup.
		 * @return void
		 */
		public static function markup( $notice = array() ) {
			wp_enqueue_script( 'astra-notices' );
			wp_enqueue_style( 'astra-notices' );

			// Dual-emit: legacy (astra_notice_*) + new (bsf_admin_notice_*) hooks for backward compat.
			// Note: consumers hooking BOTH names for the same event will be called twice.
			do_action( 'astra_notice_before_markup' );
			do_action( 'bsf_admin_notice_before_markup' );

			do_action( "astra_notice_before_markup_{$notice['id']}" );
			do_action( "bsf_admin_notice_before_markup_{$notice['id']}" );

			?>
			<div id="<?php echo esc_attr( $notice['id'] ); ?>" class="<?php echo esc_attr( 'astra-notice-wrapper ' . $notice['classes'] ); ?>" data-repeat-notice-after="<?php echo esc_attr( $notice['repeat-notice-after'] ); ?>">
				<div class="astra-notice-container">
					<?php do_action( "astra_notice_inside_markup_{$notice['id']}" ); ?>
					<?php do_action( "bsf_admin_notice_inside_markup_{$notice['id']}" ); ?>
					<?php echo wp_kses_post( $notice['message'] ); ?>
				</div>
			</div>
			<?php

			do_action( "astra_notice_after_markup_{$notice['id']}" );
			do_action( "bsf_admin_notice_after_markup_{$notice['id']}" );

			do_action( 'astra_notice_after_markup' );
			do_action( 'bsf_admin_notice_after_markup' );
		}

		/**
		 * Get wrapper classes for a notice.
		 *
		 * @since 1.2.0
		 *
		 * @param  array $notice Notice arguments.
		 * @return array       Notice wrapper classes.
		 */
		private static function get_wrap_classes( $notice ) {
			$classes = array( 'astra-notice', 'notice' );

			if ( $notice['is_dismissible'] ) {
				$classes[] = 'is-dismissible';
			}

			$classes[] = $notice['class'];
			if ( isset( $notice['type'] ) && '' !== $notice['type'] ) {
				$classes[] = 'notice-' . $notice['type'];
			}

			return esc_attr( implode( ' ', $classes ) );
		}

		/**
		 * Get HTML ID for a given notice.
		 *
		 * @since 1.2.0
		 *
		 * @param  array $notice Notice arguments.
		 * @param  int   $key    Notice array index.
		 * @return string HTML if for the notice.
		 */
		private static function get_notice_id( $notice, $key ) {
			if ( isset( $notice['id'] ) && ! empty( $notice['id'] ) ) {
				return $notice['id'];
			}

			return 'astra-notices-id-' . $key;
		}

		/**
		 * Check if the notice is expires.
		 *
		 * @since 1.2.0
		 *
		 * @param  array $notice Notice arguments.
		 * @return boolean
		 */
		private static function is_expired( $notice ) {
			$transient_status = get_transient( $notice['id'] );

			if ( false === $transient_status ) {

				if ( isset( $notice['display-notice-after'] ) && false !== $notice['display-notice-after'] ) {

					if ( 'delayed-notice' !== get_user_meta( get_current_user_id(), $notice['id'], true ) &&
						'notice-dismissed' !== get_user_meta( get_current_user_id(), $notice['id'], true ) ) {
						set_transient( $notice['id'], 'delayed-notice', $notice['display-notice-after'] );
						update_user_meta( get_current_user_id(), $notice['id'], 'delayed-notice' );

						return false;
					}
				}

				// Check the user meta status if current notice is dismissed or delay completed.
				$meta_status = get_user_meta( get_current_user_id(), $notice['id'], true );

				if ( empty( $meta_status ) || 'delayed-notice' === $meta_status ) {
					return true;
				}
			}

			return false;
		}

		/**
		 * Get base URL for the library assets.
		 *
		 * @return mixed URL.
		 */
		public static function get_url() {
			$path      = wp_normalize_path( dirname( __FILE__ ) ); // phpcs:ignore Modernize.FunctionCalls.Dirname.FileConstant
			$theme_dir = wp_normalize_path( get_template_directory() );

			if ( false !== strpos( $path, $theme_dir ) ) {
				return trailingslashit( get_template_directory_uri() . str_replace( $theme_dir, '', $path ) );
			} else {
				return plugin_dir_url( __FILE__ );
			}
		}
	}

	/**
	 * Kicking this off by calling 'get_instance()' method
	 */
	BSF_Admin_Notices::get_instance();

endif;

// Backward compatibility alias for bsf-analytics library and third-party plugins
// that still reference the old class name. Safe to remove once all consumers
// are updated.
if ( ! class_exists( 'Astra_Notices' ) ) {
	class_alias( 'BSF_Admin_Notices', 'Astra_Notices' ); // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.class_aliasFound
}
