<?php
/**
 * Server-driven Remote Banner on the The7 dashboard.
 *
 * @package The7
 */

namespace The7\Admin;

use The7_Remote_API;

defined( 'ABSPATH' ) || exit;

/**
 * Fetches, caches, renders, and dismisses the Remote Banner.
 */
class Remote_Banner {

	/**
	 * Site option key for cached banner payload.
	 *
	 * @var string
	 */
	const OPTION_KEY = 'the7_remote_banner';

	/**
	 * User meta key for dismiss state.
	 *
	 * @var string
	 */
	const USER_META_KEY = 'the7_remote_banner_dismissed';

	/**
	 * AJAX action name.
	 *
	 * @var string
	 */
	const AJAX_ACTION = 'the7_dismiss_remote_banner';

	/**
	 * Capability required to view and dismiss.
	 *
	 * @var string
	 */
	const CAPABILITY = 'edit_theme_options';

	/**
	 * Refetch interval after a successful response.
	 *
	 * @var int
	 */
	const FETCH_SUCCESS_INTERVAL = 12 * HOUR_IN_SECONDS;

	/**
	 * Refetch interval after a failed response.
	 *
	 * @var int
	 */
	const FETCH_FAILURE_INTERVAL = 2 * HOUR_IN_SECONDS;

	/**
	 * Allowed asset host.
	 *
	 * @var array
	 */
	const ALLOWED_ASSET_HOST = [ 'repo.the7.io' ];

	/**
	 * @var self|null
	 */
	private static $instance = null;

	/**
	 * @var The7_Remote_API|null
	 */
	private $remote_api;

	/**
	 * Cached record loaded during prepare().
	 *
	 * @var array|null
	 */
	private $prepared_record = null;

	/**
	 * @param The7_Remote_API|null $remote_api Optional API client (for tests).
	 */
	public function __construct( $remote_api = null ) {
		$this->remote_api = $remote_api;
	}

	/**
	 * @param The7_Remote_API|null $remote_api Optional API client (for tests).
	 *
	 * @return self
	 */
	public static function instance( $remote_api = null ) {
		if ( null === self::$instance || null !== $remote_api ) {
			self::$instance = new self( $remote_api );
		}

		return self::$instance;
	}

	/**
	 * Reset singleton (for tests).
	 *
	 * @return void
	 */
	public static function reset_instance() {
		self::$instance = null;
	}

	/**
	 * Initialize hooks.
	 *
	 * @return void
	 */
	public static function init() {
		add_action( 'wp_ajax_' . self::AJAX_ACTION, [ __CLASS__, 'handle_dismiss' ] );
		add_action( 'after_switch_theme', [ __CLASS__, 'on_theme_switch' ] );
		add_action( 'the7_after_theme_activation', [ __CLASS__, 'invalidate_cache' ] );
		add_action( 'the7_after_theme_deactivation', [ __CLASS__, 'invalidate_cache' ] );
	}

	/**
	 * Reset dismiss state and banner cache after a WordPress theme switch.
	 *
	 * @return void
	 */
	public static function on_theme_switch() {
		self::reset_dismissals();
		self::invalidate_cache();
	}

	/**
	 * Delete cached banner payload so the next dashboard load refetches from the API.
	 *
	 * @return void
	 */
	public static function invalidate_cache() {
		delete_option( self::OPTION_KEY );
	}

	/**
	 * Clear per-user dismiss/snooze state (e.g. after theme activation).
	 *
	 * @return void
	 */
	public static function reset_dismissals() {
		delete_metadata( 'user', 0, self::USER_META_KEY, null, true );
	}

	/**
	 * Check whether a remote asset URL may be enqueued.
	 *
	 * @param string $url Asset URL.
	 *
	 * @return bool
	 */
	public static function is_allowed_asset_url( $url ) {
		if ( ! is_string( $url ) || '' === $url ) {
			return false;
		}

		$parts = wp_parse_url( $url );

		if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return false;
		}

		return 'https' === $parts['scheme'] && in_array( $parts['host'], self::ALLOWED_ASSET_HOST, true );
	}

	/**
	 * Sanitize server-authored banner HTML.
	 *
	 * @param string $html Raw HTML.
	 *
	 * @return string
	 */
	public static function sanitize_banner_html( $html ) {
		return wp_kses_post( $html );
	}

	/**
	 * Apply a banners API response to the cached record.
	 *
	 * @param array         $record     Current option record.
	 * @param array|WP_Error $api_result Decoded API response or error.
	 *
	 * @return array Updated record.
	 */
	public static function apply_api_payload( array $record, $api_result ) {
		if ( is_wp_error( $api_result ) || ! self::is_successful_api_response( $api_result ) ) {
			return self::apply_fetch_failure( $record );
		}

		$data = $api_result['data'] ?? null;

		if ( empty( $data ) ) {
			return self::build_empty_record( time() + self::FETCH_SUCCESS_INTERVAL );
		}

		if ( ! is_array( $data ) || empty( $data['id'] ) ) {
			return self::apply_fetch_failure( $record );
		}

		$next_fetch_at = time() + self::FETCH_SUCCESS_INTERVAL;

		if ( self::is_timing_only_payload( $data, $record ) ) {
			return self::merge_timing_only( $record, $data, $next_fetch_at );
		}

		return self::merge_full_payload( $data, $next_fetch_at );
	}

	/**
	 * Whether the banner should be visible for the current user.
	 *
	 * @param array|null $record Option record; uses prepared record when null.
	 *
	 * @return bool
	 */
	public function is_visible( $record = null ) {
		if ( null === $record ) {
			$record = $this->get_prepared_record();
		}

		if ( ! self::record_has_banner( $record ) ) {
			return false;
		}

		if ( ! current_user_can( self::CAPABILITY ) ) {
			return false;
		}

		$dismissal = get_user_meta( get_current_user_id(), self::USER_META_KEY, true );

		if ( ! is_array( $dismissal ) || empty( $dismissal['id'] ) || empty( $dismissal['dismissed_at'] ) ) {
			return true;
		}

		if ( (string) $dismissal['id'] !== (string) $record['id'] ) {
			return true;
		}

		$snooze_ends = (int) $dismissal['dismissed_at'] + (int) $record['snooze_duration'];

		return time() >= $snooze_ends;
	}

	/**
	 * Fetch if due, stash record, enqueue assets when visible.
	 *
	 * @return void
	 */
	public function prepare() {
		$record = $this->get_record();

		if ( time() >= (int) ( $record['next_fetch_at'] ?? 0 ) ) {
			$held_id    = self::record_has_banner( $record ) ? (string) $record['id'] : '';
			$api_result = $this->get_remote_api()->get_banner( $held_id );
			$record     = self::apply_api_payload( $record, $api_result );
			$this->save_record( $record );
		}

		$this->prepared_record = $record;

		if ( ! $this->is_visible( $record ) ) {
			return;
		}

		$this->enqueue_assets( $record );
		$this->enqueue_dismiss_script();
	}

	/**
	 * Render the banner when visible.
	 *
	 * @return void
	 */
	public function render() {
		$record = $this->get_prepared_record();

		if ( ! $this->is_visible( $record ) ) {
			return;
		}

		$html = isset( $record['html'] ) ? (string) $record['html'] : '';
		?>
		<div class="the7-postbox the7-remote-banner">
			<?php echo self::sanitize_banner_html( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_kses_post. ?>
			<button type="button" class="notice-dismiss" id="the7-remote-banner-close"></button>
		</div>
		<?php
	}

	/**
	 * Handle AJAX dismiss request.
	 *
	 * @return void
	 */
	public static function handle_dismiss() {
		check_ajax_referer( self::AJAX_ACTION, 'nonce' );

		if ( ! current_user_can( self::CAPABILITY ) ) {
			wp_send_json_error( [ 'message' => __( 'You are not allowed to perform this action.', 'the7mk2' ) ] );
		}

		$banner = self::instance()->get_record();

		if ( ! self::record_has_banner( $banner ) ) {
			wp_send_json_error( [ 'message' => __( 'No banner to dismiss.', 'the7mk2' ) ] );
		}

		update_user_meta(
			get_current_user_id(),
			self::USER_META_KEY,
			[
				'id'           => (string) $banner['id'],
				'dismissed_at' => time(),
			]
		);

		wp_send_json_success();
	}

	/**
	 * @return array
	 */
	public function get_record() {
		$record = get_option( self::OPTION_KEY, false );

		if ( false === $record || ! is_array( $record ) ) {
			return self::build_empty_record( 0 );
		}

		return $record;
	}

	/**
	 * @return array
	 */
	public function get_prepared_record() {
		if ( null === $this->prepared_record ) {
			$this->prepared_record = $this->get_record();
		}

		return $this->prepared_record;
	}

	/**
	 * @param array $record Banner option record.
	 *
	 * @return void
	 */
	protected function save_record( array $record ) {
		update_option( self::OPTION_KEY, $record, false );
	}

	/**
	 * @return The7_Remote_API
	 */
	protected function get_remote_api() {
		if ( null === $this->remote_api ) {
			$this->remote_api = new The7_Remote_API( presscore_get_purchase_code() );
		}

		return $this->remote_api;
	}

	/**
	 * @param array $record Banner record.
	 *
	 * @return void
	 */
	protected function enqueue_assets( array $record ) {
		$styles  = isset( $record['styles'] ) && is_array( $record['styles'] ) ? $record['styles'] : [];
		$scripts = isset( $record['scripts'] ) && is_array( $record['scripts'] ) ? $record['scripts'] : [];

		foreach ( $styles as $index => $url ) {
			if ( ! self::is_allowed_asset_url( $url ) ) {
				continue;
			}

			$handle = 'the7-remote-banner-style-' . $index;
			wp_enqueue_style( $handle, $url, [], null );
		}

		foreach ( $scripts as $index => $url ) {
			if ( ! self::is_allowed_asset_url( $url ) ) {
				continue;
			}

			$handle = 'the7-remote-banner-script-' . $index;
			wp_enqueue_script( $handle, $url, [ 'jquery' ], null, true );
		}
	}

	/**
	 * @return void
	 */
	protected function enqueue_dismiss_script() {
		$handle = 'the7-remote-banner';

		the7_register_script(
			$handle,
			PRESSCORE_ADMIN_URI . '/assets/js/remote-banner.js',
			[ 'jquery' ]
		);

		wp_localize_script(
			$handle,
			'the7RemoteBanner',
			[
				'action' => self::AJAX_ACTION,
				'nonce'  => wp_create_nonce( self::AJAX_ACTION ),
			]
		);

		wp_enqueue_script( $handle );
	}

	/**
	 * @param int $next_fetch_at Next fetch timestamp.
	 *
	 * @return array
	 */
	protected static function build_empty_record( $next_fetch_at ) {
		return [
			'banner'        => null,
			'next_fetch_at' => (int) $next_fetch_at,
		];
	}

	/**
	 * @param array $record Option record.
	 *
	 * @return bool
	 */
	protected static function record_has_banner( array $record ) {
		return ! empty( $record['id'] );
	}

	/**
	 * @param array|WP_Error $api_result API response.
	 *
	 * @return bool
	 */
	protected static function is_successful_api_response( $api_result ) {
		return is_array( $api_result ) && ! empty( $api_result['success'] );
	}

	/**
	 * @param array $record Current record.
	 *
	 * @return array
	 */
	protected static function apply_fetch_failure( array $record ) {
		$record['next_fetch_at'] = time() + self::FETCH_FAILURE_INTERVAL;

		return $record;
	}

	/**
	 * @param array $data   API data payload.
	 * @param array $record Current record.
	 *
	 * @return bool
	 */
	protected static function is_timing_only_payload( array $data, array $record ) {
		if ( ! self::record_has_banner( $record ) ) {
			return false;
		}

		if ( (string) $data['id'] !== (string) $record['id'] ) {
			return false;
		}

		if ( isset( $data['html'] ) ) {
			return false;
		}

		if ( ! empty( $data['styles'] ) || ! empty( $data['scripts'] ) ) {
			return false;
		}

		return true;
	}

	/**
	 * @param array $record        Current record.
	 * @param array $data          Timing fields from API.
	 * @param int   $next_fetch_at Next fetch timestamp.
	 *
	 * @return array
	 */
	protected static function merge_timing_only( array $record, array $data, $next_fetch_at ) {
		$record['snooze_duration']  = (int) $data['snooze_duration'];
		$record['next_fetch_at']    = (int) $next_fetch_at;

		return $record;
	}

	/**
	 * @param array $data          Full API data payload.
	 * @param int   $next_fetch_at Next fetch timestamp.
	 *
	 * @return array
	 */
	protected static function merge_full_payload( array $data, $next_fetch_at ) {
		return [
			'id'               => (string) $data['id'],
			'html'             => isset( $data['html'] ) ? (string) $data['html'] : '',
			'styles'           => isset( $data['styles'] ) && is_array( $data['styles'] ) ? array_values( $data['styles'] ) : [],
			'scripts'          => isset( $data['scripts'] ) && is_array( $data['scripts'] ) ? array_values( $data['scripts'] ) : [],
			'snooze_duration'  => (int) $data['snooze_duration'],
			'next_fetch_at'    => (int) $next_fetch_at,
		];
	}

}
