<?php
/**
 * Demo Lock rule module.
 *
 * @package Polanger_Shield
 */

if ( ! defined( 'ABSPATH' ) ) {
  exit;
}

class Polanger_Shield_Demo_Lock {

  /**
   * Query arg used after blocked save attempts.
   *
   * @var string
   */
  const NOTICE_QUERY_ARG = 'polanger_shield_demo_locked';
  const REQUEST_RULE_KEY = 'polanger_shield_demo_lock_rule';
  const REQUEST_TOKEN_KEY = 'polanger_shield_demo_lock_token';
  const HEADER_RULE_KEY  = 'HTTP_X_POLANGER_SHIELD_DEMO_LOCK_RULE';
  const HEADER_TOKEN_KEY = 'HTTP_X_POLANGER_SHIELD_DEMO_LOCK';

  /**
   * Default rule meta for demo lock.
   *
   * @var array
   */
  const DEFAULT_RULE_META = array(
    'show_notice'            => true,
    'disable_controls'       => true,
    'block_form_submissions' => true,
    'block_ajax_requests'    => true,
    'block_rest_requests'    => true,
  );

  /**
   * Singleton.
   *
   * @var Polanger_Shield_Demo_Lock|null
   */
  private static $instance = null;

  /**
   * Per-request cache for whether active demo-lock rules exist.
   *
   * @var bool|null
   */
  private $has_active_rules_cache = null;

  /**
   * Per-request cache for context rule matches.
   *
   * @var array<string, object|null>
   */
  private $context_rule_cache = array();

  /**
   * Per-request cache for write-request rule matches.
   *
   * @var array<string, object|null>
   */
  private $write_rule_cache = array();

  /**
   * Per-request cache for transport rule matches.
   *
   * @var array<int, object|null>
   */
  private $transport_rule_cache = array();

  /**
   * Per-request cache for the parsed referrer context.
   *
   * @var array|null
   */
  private $referrer_context_cache = null;

  /**
   * Per-request cache for REST-request detection.
   *
   * @var bool|null
   */
  private $rest_request_cache = null;

  /**
   * Whether runtime hooks have already been attached for this request.
   *
   * @var bool
   */
  private $runtime_hooks_registered = false;

  /**
   * Get singleton instance.
   *
   * @return Polanger_Shield_Demo_Lock
   */
  public static function instance() {
    if ( null === self::$instance ) {
      self::$instance = new self();
    }

    return self::$instance;
  }

  /**
   * Register hooks.
   *
   * @return void
   */
  public function register_hooks() {
    add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_builder_assets' ), 108 );
    add_action( 'init', array( $this, 'register_runtime_hooks' ), 0 );
  }

  /**
   * Register runtime hooks once WordPress pluggable functions are ready.
   *
   * @return void
   */
  public function register_runtime_hooks() {
    if ( $this->runtime_hooks_registered ) {
      return;
    }

    if ( ! function_exists( 'is_user_logged_in' ) || ! is_user_logged_in() || ! $this->has_active_rules() ) {
      return;
    }

    if ( ! is_admin() && ! wp_doing_ajax() && ! $this->is_rest_request() ) {
      return;
    }

    $this->runtime_hooks_registered = true;

    add_action( 'admin_init', array( $this, 'maybe_block_unsafe_screen_load' ), 0 );
    add_action( 'admin_init', array( $this, 'maybe_block_dangerous_get_request' ), 0 );
    add_action( 'admin_init', array( $this, 'maybe_block_form_submission' ), 0 );
    add_action( 'admin_init', array( $this, 'maybe_block_ajax_request' ), 0 );
    add_filter( 'rest_pre_dispatch', array( $this, 'maybe_block_rest_request' ), 10, 3 );
    add_action( 'admin_notices', array( $this, 'render_block_notice' ) );
    add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_runtime_assets' ), 109 );
  }

  /**
   * Sanitize demo lock rule meta.
   *
   * @param mixed $rule_meta Raw meta.
   * @return array
   */
  public static function sanitize_rule_meta( $rule_meta ) {
    if ( is_string( $rule_meta ) && '' !== $rule_meta ) {
      $decoded = json_decode( $rule_meta, true );
      if ( is_array( $decoded ) ) {
        $rule_meta = $decoded;
      }
    }

    if ( ! is_array( $rule_meta ) ) {
      $rule_meta = array();
    }

    return array(
      'show_notice'            => ! empty( $rule_meta['show_notice'] ),
      'disable_controls'       => ! empty( $rule_meta['disable_controls'] ),
      'block_form_submissions' => ! empty( $rule_meta['block_form_submissions'] ),
      'block_ajax_requests'    => ! empty( $rule_meta['block_ajax_requests'] ),
      'block_rest_requests'    => ! empty( $rule_meta['block_rest_requests'] ),
    );
  }

  /**
   * Read demo lock meta from a rule row.
   *
   * @param object|array $rule Rule row.
   * @return array
   */
  public static function get_rule_meta( $rule ) {
    $raw_meta = array();

    if ( is_object( $rule ) && isset( $rule->rule_meta ) ) {
      $raw_meta = $rule->rule_meta;
    } elseif ( is_array( $rule ) && isset( $rule['rule_meta'] ) ) {
      $raw_meta = $rule['rule_meta'];
    }

    return self::sanitize_rule_meta( $raw_meta );
  }

  /**
   * Enqueue builder enhancements for Demo Lock.
   *
   * @return void
   */
  public function enqueue_builder_assets() {
    if ( ! is_admin() || Polanger_Shield_Safe_Mode::is_active() ) {
      return;
    }

    if ( ! Polanger_Shield_Permissions::current_user_can_manage_shield( true ) ) {
      return;
    }

    wp_enqueue_style(
      'polanger-shield-demo-lock',
      POLANGER_SHIELD_URL . 'assets/css/shield-demo-lock.css',
      array(),
      POLANGER_SHIELD_VERSION
    );

    wp_enqueue_script(
      'polanger-shield-demo-lock-builder',
      POLANGER_SHIELD_URL . 'assets/js/shield-demo-lock-builder.js',
      array( 'jquery', 'polanger-shield-builder' ),
      POLANGER_SHIELD_VERSION,
      true
    );

    wp_localize_script(
      'polanger-shield-demo-lock-builder',
      'polangerShieldDemoLockBuilder',
      array(
        'defaults' => self::sanitize_rule_meta( self::DEFAULT_RULE_META ),
        'i18n'     => array(
          'title'              => __( 'Lock This Page for Demo', 'polanger-admin-menu-manager' ),
          'description'        => __( 'Selected administrators can explore this page, but Shield will turn it into a view-only demo screen and block save attempts.', 'polanger-admin-menu-manager' ),
          'saveRule'           => __( 'Save Demo Lock', 'polanger-admin-menu-manager' ),
          'cancel'             => __( 'Cancel', 'polanger-admin-menu-manager' ),
          'selectUserWarning'  => __( 'Please select at least one user.', 'polanger-admin-menu-manager' ),
          'saveFailed'         => __( 'Demo Lock rule could not be saved.', 'polanger-admin-menu-manager' ),
          'saveSuccess'        => __( 'Demo Lock rule saved successfully.', 'polanger-admin-menu-manager' ),
          'affectedUsersLabel' => __( 'Affected users', 'polanger-admin-menu-manager' ),
          'optionIntro'        => __( 'Choose how strict this demo protection should be for the current admin screen.', 'polanger-admin-menu-manager' ),
          'optionNoticeLabel'  => __( 'Show a clear demo-mode notice on the page', 'polanger-admin-menu-manager' ),
          'optionNoticeText'   => __( 'Lets the user know they are browsing a protected demo screen and that changes are intentionally disabled.', 'polanger-admin-menu-manager' ),
          'optionVisualLabel'  => __( 'Dim or disable common save controls visually', 'polanger-admin-menu-manager' ),
          'optionVisualText'   => __( 'Marks primary save and publish controls as unavailable before the user even clicks them.', 'polanger-admin-menu-manager' ),
          'optionFormsLabel'   => __( 'Block standard form submissions', 'polanger-admin-menu-manager' ),
          'optionFormsText'    => __( 'Prevents settings forms, classic editor saves, and similar POST requests from completing.', 'polanger-admin-menu-manager' ),
          'optionAjaxLabel'    => __( 'Block AJAX write requests from this page', 'polanger-admin-menu-manager' ),
          'optionAjaxText'     => __( 'Blocks non-GET admin-ajax requests from this screen while still allowing normal GET-based background reads.', 'polanger-admin-menu-manager' ),
          'optionRestLabel'    => __( 'Block REST write requests from this page', 'polanger-admin-menu-manager' ),
          'optionRestText'     => __( 'Protects modern interfaces that submit changes through the WordPress REST API.', 'polanger-admin-menu-manager' ),
        ),
      )
    );
  }

  /**
   * Enqueue runtime Demo Lock assets on targeted screens.
   *
   * @return void
   */
  public function enqueue_runtime_assets() {
    if ( ! is_admin() || Polanger_Shield_Safe_Mode::is_active() ) {
      return;
    }

    $user_id = get_current_user_id();
    if ( ! $user_id ) {
      return;
    }

    $rule = $this->get_matching_rule_for_write_request( $user_id, true );
    if ( ! $rule ) {
      return;
    }

    $rule_meta = self::get_rule_meta( $rule );

    wp_enqueue_style(
      'polanger-shield-demo-lock',
      POLANGER_SHIELD_URL . 'assets/css/shield-demo-lock.css',
      array(),
      POLANGER_SHIELD_VERSION
    );

    wp_enqueue_script(
      'polanger-shield-demo-lock-runtime',
      POLANGER_SHIELD_URL . 'assets/js/shield-demo-lock-runtime.js',
      array( 'jquery' ),
      POLANGER_SHIELD_VERSION,
      true
    );

    wp_localize_script(
      'polanger-shield-demo-lock-runtime',
      'polangerShieldDemoLockRuntime',
      array(
        'ruleId'               => absint( $rule->id ),
        'showNotice'           => ! empty( $rule_meta['show_notice'] ),
        'disableControls'      => ! empty( $rule_meta['disable_controls'] ),
        'blockFormSubmissions' => ! empty( $rule_meta['block_form_submissions'] ),
        'blockAjaxRequests'    => ! empty( $rule_meta['block_ajax_requests'] ),
        'blockRestRequests'    => ! empty( $rule_meta['block_rest_requests'] ),
        'transport'            => array(
          'ruleId'        => absint( $rule->id ),
          'token'         => $this->create_transport_token( $rule ),
          'ruleField'     => self::REQUEST_RULE_KEY,
          'tokenField'    => self::REQUEST_TOKEN_KEY,
          'ruleHeader'    => 'X-Polanger-Shield-Demo-Lock-Rule',
          'tokenHeader'   => 'X-Polanger-Shield-Demo-Lock',
          'lockedLabel'   => __( 'Demo Locked', 'polanger-admin-menu-manager' ),
        ),
        'i18n'                 => array(
          'bannerTitle'   => __( 'Demo Lock Active', 'polanger-admin-menu-manager' ),
          'bannerText'    => __( 'You can review this admin screen, but save buttons, form submissions, and write requests are disabled for your account.', 'polanger-admin-menu-manager' ),
          'dismissLabel'  => __( 'Understood', 'polanger-admin-menu-manager' ),
          'actionBlocked' => __( 'This admin screen is running in demo mode. Changes are disabled for your account.', 'polanger-admin-menu-manager' ),
          'formBlocked'   => __( 'Form submission blocked by Demo Lock.', 'polanger-admin-menu-manager' ),
        ),
      )
    );
  }

  /**
   * Block rule-targeted screens that create data on initial load.
   *
   * @return void
   */
  public function maybe_block_unsafe_screen_load() {
    if ( ! is_admin() || wp_doing_ajax() || Polanger_Shield_Safe_Mode::is_active() ) {
      return;
    }

    if ( 'GET' !== $this->get_request_method() || ! $this->is_unsafe_screen_load_request() ) {
      return;
    }

    $user_id = get_current_user_id();
    if ( ! $user_id ) {
      return;
    }

    $rule = $this->get_matching_rule_for_context( $user_id, Polanger_Shield_Context::current() );
    if ( ! $rule ) {
      return;
    }

    $this->log_block_event(
      $rule,
      $user_id,
      'screen_load',
      array(
        'request_uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
      )
    );

    wp_safe_redirect(
      add_query_arg(
        self::NOTICE_QUERY_ARG,
        absint( $rule->id ),
        $this->get_fallback_redirect_url()
      )
    );
    exit;
  }

  /**
   * Block classic GET actions triggered from a demo-locked admin screen.
   *
   * @return void
   */
  public function maybe_block_dangerous_get_request() {
    if ( ! is_admin() || wp_doing_ajax() || Polanger_Shield_Safe_Mode::is_active() ) {
      return;
    }

    if ( 'GET' !== $this->get_request_method() ) {
      return;
    }

    $user_id = get_current_user_id();
    if ( ! $user_id ) {
      return;
    }

    $action = $this->get_requested_action();
    if ( '' === $action || ! $this->is_dangerous_get_action( $action ) ) {
      return;
    }

    $rule = $this->get_matching_rule_for_context( $user_id, Polanger_Shield_Context::current() );
    if ( ! $rule ) {
      return;
    }

    $this->log_block_event(
      $rule,
      $user_id,
      'get',
      array(
        'admin_action' => $action,
        'request_uri'  => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
      )
    );

    wp_safe_redirect(
      add_query_arg(
        self::NOTICE_QUERY_ARG,
        absint( $rule->id ),
        $this->get_fallback_redirect_url()
      )
    );
    exit;
  }

  /**
   * Block classic/admin form submissions coming from a locked page.
   *
   * @return void
   */
  public function maybe_block_form_submission() {
    if ( ! is_admin() || wp_doing_ajax() || Polanger_Shield_Safe_Mode::is_active() ) {
      return;
    }

    if ( 'POST' !== $this->get_request_method() ) {
      return;
    }

    $user_id = get_current_user_id();
    if ( ! $user_id ) {
      return;
    }

    $rule = $this->get_matching_rule_for_write_request( $user_id, true );
    if ( ! $rule ) {
      return;
    }

    $rule_meta = self::get_rule_meta( $rule );
    if ( empty( $rule_meta['block_form_submissions'] ) ) {
      return;
    }

    $this->log_block_event(
      $rule,
      $user_id,
      'form',
      array(
        'request_uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '',
      )
    );

    wp_safe_redirect(
      add_query_arg(
        self::NOTICE_QUERY_ARG,
        absint( $rule->id ),
        $this->get_fallback_redirect_url()
      )
    );
    exit;
  }

  /**
   * Block likely mutating admin-ajax requests coming from a locked page.
   *
   * @return void
   */
  public function maybe_block_ajax_request() {
    if ( ! wp_doing_ajax() || Polanger_Shield_Safe_Mode::is_active() ) {
      return;
    }

    $method = $this->get_request_method();
    if ( ! in_array( $method, array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
      return;
    }

    $user_id = get_current_user_id();
    if ( ! $user_id ) {
      return;
    }

    $rule = $this->get_matching_rule_for_write_request( $user_id, true );
    if ( ! $rule ) {
      return;
    }

    $rule_meta = self::get_rule_meta( $rule );
    if ( empty( $rule_meta['block_ajax_requests'] ) ) {
      return;
    }

    $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';
    $is_mutating = 'GET' === $method
      ? ( '' !== $action && ( $this->is_dangerous_get_action( $action ) || $this->is_potentially_mutating_ajax_action( $action, false ) ) )
      : true;

    if ( ! $is_mutating ) {
      return;
    }

    $this->log_block_event(
      $rule,
      $user_id,
      'ajax',
      array(
        'request_method' => $method,
        'ajax_action' => $action,
      )
    );

    wp_send_json_error(
      array(
        'message' => __( 'This admin screen is in demo mode. AJAX changes are disabled for your account.', 'polanger-admin-menu-manager' ),
      ),
      403
    );
  }

  /**
   * Block REST write requests coming from a locked admin page.
   *
   * @param mixed           $result  Current pre-dispatch result.
   * @param WP_REST_Server  $server  REST server.
   * @param WP_REST_Request $request Request object.
   * @return mixed
   */
  public function maybe_block_rest_request( $result, $server, $request ) {
    unset( $server );

    if ( Polanger_Shield_Safe_Mode::is_active() ) {
      return $result;
    }

    $method = strtoupper( $request->get_method() );
    if ( ! in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
      return $result;
    }

    $user_id = get_current_user_id();
    if ( ! $user_id ) {
      return $result;
    }

    $rule = $this->get_matching_rule_for_write_request( $user_id, true );
    if ( ! $rule ) {
      return $result;
    }

    $rule_meta = self::get_rule_meta( $rule );
    if ( empty( $rule_meta['block_rest_requests'] ) ) {
      return $result;
    }

    $this->log_block_event(
      $rule,
      $user_id,
      'rest',
      array(
        'rest_method' => $method,
        'rest_route'  => sanitize_text_field( (string) $request->get_route() ),
      )
    );

    return new WP_Error(
      'polanger_shield_demo_locked',
      __( 'This admin screen is in demo mode. REST changes are disabled for your account.', 'polanger-admin-menu-manager' ),
      array(
        'status' => 403,
      )
    );
  }

  /**
   * Render a friendly admin notice after blocked submissions.
   *
   * @return void
   */
  public function render_block_notice() {
    if ( ! is_admin() ) {
      return;
    }

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $rule_id = isset( $_GET[ self::NOTICE_QUERY_ARG ] ) ? absint( wp_unslash( $_GET[ self::NOTICE_QUERY_ARG ] ) ) : 0;
    if ( ! $rule_id ) {
      return;
    }
    ?>
    <div class="notice notice-warning">
      <p>
        <?php esc_html_e( 'Demo Lock blocked this action. This admin screen is currently view-only for your account.', 'polanger-admin-menu-manager' ); ?>
      </p>
    </div>
    <?php
  }

  /**
   * Find the first matching Demo Lock rule for a user/context.
   *
   * @param int   $user_id User ID.
   * @param array $context Screen context.
   * @return object|null
   */
  public function get_matching_rule_for_context( $user_id, $context ) {
    $user_id = absint( $user_id );
    if ( ! $user_id || empty( $context ) || ! is_array( $context ) ) {
      return null;
    }

    $cache_key = $user_id . ':' . md5( maybe_serialize( $context ) );
    if ( array_key_exists( $cache_key, $this->context_rule_cache ) ) {
      return $this->context_rule_cache[ $cache_key ];
    }

    $rules = Polanger_Shield_Rules::get_active_rules_for_context( 'demo_lock', $context );
    if ( empty( $rules ) ) {
      $this->context_rule_cache[ $cache_key ] = null;
      return null;
    }

    foreach ( $rules as $rule ) {
      if ( Polanger_Shield_Permissions::is_user_targeted_by_rule( $rule, $user_id ) ) {
        $this->context_rule_cache[ $cache_key ] = $rule;
        return $this->context_rule_cache[ $cache_key ];
      }
    }

    $this->context_rule_cache[ $cache_key ] = null;

    return null;
  }

  /**
   * Find a matching Demo Lock rule for write requests by checking referrer first.
   *
   * @param int  $user_id               User ID.
   * @param bool $allow_referrer_fallback Whether referrer fallback may be used.
   * @return object|null
   */
  private function get_matching_rule_for_write_request( $user_id, $allow_referrer_fallback = false ) {
    $cache_key = absint( $user_id ) . ':' . ( $allow_referrer_fallback ? '1' : '0' );
    if ( array_key_exists( $cache_key, $this->write_rule_cache ) ) {
      return $this->write_rule_cache[ $cache_key ];
    }

    $rule = $this->get_transport_rule_from_request( $user_id );
    if ( $rule ) {
      $this->write_rule_cache[ $cache_key ] = $rule;
      return $this->write_rule_cache[ $cache_key ];
    }

    if ( ! $allow_referrer_fallback ) {
      $this->write_rule_cache[ $cache_key ] = null;
      return null;
    }

    $contexts = array();
    $referrer_context = $this->get_referrer_context();

    if ( ! empty( array_filter( $referrer_context ) ) ) {
      $contexts[] = $referrer_context;
    }

    $contexts[] = Polanger_Shield_Context::current();

    foreach ( $contexts as $context ) {
      $rule = $this->get_matching_rule_for_context( $user_id, $context );
      if ( $rule ) {
        $this->write_rule_cache[ $cache_key ] = $rule;
        return $this->write_rule_cache[ $cache_key ];
      }
    }

    $this->write_rule_cache[ $cache_key ] = null;

    return null;
  }

  /**
   * Parse current admin referrer into Shield context.
   *
   * @return array
   */
  private function get_referrer_context() {
    if ( null !== $this->referrer_context_cache ) {
      return $this->referrer_context_cache;
    }

    $referrer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
    if ( '' === $referrer ) {
      $this->referrer_context_cache = array();
      return $this->referrer_context_cache;
    }

    $referrer_host = (string) wp_parse_url( $referrer, PHP_URL_HOST );
    $admin_host    = (string) wp_parse_url( admin_url(), PHP_URL_HOST );
    if ( '' !== $admin_host && '' !== $referrer_host && strtolower( $referrer_host ) !== strtolower( $admin_host ) ) {
      $this->referrer_context_cache = array();
      return $this->referrer_context_cache;
    }

    $this->referrer_context_cache = Polanger_Shield_Context::from_url( $referrer );

    return $this->referrer_context_cache;
  }

  /**
   * Resolve a demo-lock rule from explicit transport fields/headers.
   *
   * @param int $user_id User ID.
   * @return object|null
   */
  private function get_transport_rule_from_request( $user_id ) {
    $user_id = absint( $user_id );
    if ( array_key_exists( $user_id, $this->transport_rule_cache ) ) {
      return $this->transport_rule_cache[ $user_id ];
    }

    $rule_id = absint( $this->get_transport_value( self::REQUEST_RULE_KEY, self::HEADER_RULE_KEY ) );
    $token   = $this->get_transport_value( self::REQUEST_TOKEN_KEY, self::HEADER_TOKEN_KEY );

    if ( ! $rule_id || '' === $token ) {
      $this->transport_rule_cache[ $user_id ] = null;
      return null;
    }

    if ( ! wp_verify_nonce( $token, $this->get_transport_action( $rule_id ) ) ) {
      $this->transport_rule_cache[ $user_id ] = null;
      return null;
    }

    $rule = Polanger_Shield_Rules::get_by_id( $rule_id );
    if ( ! $rule || 'active' !== (string) $rule->status || 'demo_lock' !== sanitize_key( (string) $rule->rule_type ) ) {
      $this->transport_rule_cache[ $user_id ] = null;
      return null;
    }

    if ( ! Polanger_Shield_Permissions::is_user_targeted_by_rule( $rule, $user_id ) ) {
      $this->transport_rule_cache[ $user_id ] = null;
      return null;
    }

    $this->transport_rule_cache[ $user_id ] = $rule;

    return $this->transport_rule_cache[ $user_id ];
  }

  /**
   * Whether any active demo-lock rules exist.
   *
   * @return bool
   */
  private function has_active_rules() {
    if ( null !== $this->has_active_rules_cache ) {
      return $this->has_active_rules_cache;
    }

    $this->has_active_rules_cache = ! empty( Polanger_Shield_Rules::get_active_rules( 'demo_lock' ) );

    return $this->has_active_rules_cache;
  }

  /**
   * Whether the current request is a REST request.
   *
   * @return bool
   */
  private function is_rest_request() {
    if ( null !== $this->rest_request_cache ) {
      return $this->rest_request_cache;
    }

    if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
      $this->rest_request_cache = true;
      return $this->rest_request_cache;
    }

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    if ( isset( $_GET['rest_route'] ) || isset( $_REQUEST['rest_route'] ) ) {
      $this->rest_request_cache = true;
      return $this->rest_request_cache;
    }

    $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
    if ( '' === $request_uri ) {
      $this->rest_request_cache = false;
      return $this->rest_request_cache;
    }

    $request_path = wp_normalize_path( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) );
    $rest_path    = wp_normalize_path( (string) wp_parse_url( rest_url(), PHP_URL_PATH ) );

    $this->rest_request_cache = '' !== $request_path && '' !== $rest_path && 0 === strpos( $request_path, $rest_path );

    return $this->rest_request_cache;
  }

  /**
   * Resolve redirect target after blocked submissions.
   *
   * @return string
   */
  private function get_fallback_redirect_url() {
    $referrer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
    if ( '' !== $referrer ) {
      return wp_validate_redirect( remove_query_arg( self::NOTICE_QUERY_ARG, $referrer ), admin_url() );
    }

    return admin_url();
  }

  /**
   * Create a transport nonce for one rule.
   *
   * @param object $rule Rule row.
   * @return string
   */
  private function create_transport_token( $rule ) {
    $rule_id = isset( $rule->id ) ? absint( $rule->id ) : 0;
    if ( ! $rule_id ) {
      return '';
    }

    return wp_create_nonce( $this->get_transport_action( $rule_id ) );
  }

  /**
   * Build the nonce action for transport tokens.
   *
   * @param int $rule_id Rule ID.
   * @return string
   */
  private function get_transport_action( $rule_id ) {
    return 'polanger_shield_demo_lock_transport_' . absint( $rule_id );
  }

  /**
   * Read one transport value from request body/query or HTTP headers.
   *
   * @param string $request_key Request field key.
   * @param string $server_key  Server header key.
   * @return string
   */
  private function get_transport_value( $request_key, $server_key ) {
    if ( isset( $_REQUEST[ $request_key ] ) ) {
      return sanitize_text_field( wp_unslash( (string) $_REQUEST[ $request_key ] ) );
    }

    if ( isset( $_SERVER[ $server_key ] ) ) {
      return sanitize_text_field( wp_unslash( (string) $_SERVER[ $server_key ] ) );
    }

    return '';
  }

  /**
   * Return the requested action slug from query/body.
   *
   * @return string
   */
  private function get_requested_action() {
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : '';

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
    if ( ( '' === $action || '-1' === $action ) && isset( $_REQUEST['action2'] ) ) {
      // phpcs:ignore WordPress.Security.NonceVerification.Recommended
      $action = sanitize_key( wp_unslash( $_REQUEST['action2'] ) );
    }

    return $action;
  }

  /**
   * Determine whether a GET action looks destructive.
   *
   * @param string $action Action slug.
   * @return bool
   */
  private function is_dangerous_get_action( $action ) {
    $action = sanitize_key( (string) $action );

    if ( '' === $action || '-1' === $action ) {
      return false;
    }

    $dangerous = array(
      'activate',
      'deactivate',
      'delete',
      'trash',
      'untrash',
      'remove',
      'install-plugin',
      'install-theme',
      'upgrade-plugin',
      'upgrade-theme',
      'switch-theme',
      'toggle-auto-update',
      'enable',
      'disable',
    );

    if ( in_array( $action, $dangerous, true ) ) {
      return true;
    }

    $tokens = array(
      'delete',
      'remove',
      'activate',
      'deactivate',
      'install',
      'upgrade',
      'update',
      'switch',
      'trash',
      'reset',
    );

    foreach ( $tokens as $token ) {
      if ( false !== strpos( $action, $token ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Determine whether the current admin screen is unsafe for read-only demos.
   *
   * @return bool
   */
  private function is_unsafe_screen_load_request() {
    $current_page = $this->current_admin_page();
    if ( '' === $current_page ) {
      return false;
    }

    return in_array( $current_page, $this->get_unsafe_screen_pages(), true );
  }

  /**
   * Return admin pages that can write data during the first load.
   *
   * @return array
   */
  private function get_unsafe_screen_pages() {
    $pages = array(
      'post-new.php',
    );

    $pages = apply_filters( 'polanger_shield_demo_lock_unsafe_admin_pages', $pages );

    if ( ! is_array( $pages ) ) {
      $pages = array();
    }

    $pages = array_map( 'sanitize_file_name', $pages );
    $pages = array_map( 'trim', $pages );

    return array_values( array_unique( array_filter( $pages ) ) );
  }

  /**
   * Return the current admin page file name.
   *
   * @return string
   */
  private function current_admin_page() {
    global $pagenow;

    return is_string( $pagenow ) ? sanitize_file_name( $pagenow ) : '';
  }

  /**
   * Determine whether an ajax action looks mutating.
   *
   * @param string $action Ajax action.
   * @param bool   $allow_empty Whether an empty action should be treated as risky.
   * @return bool
   */
  private function is_potentially_mutating_ajax_action( $action, $allow_empty = true ) {
    $action = sanitize_key( (string) $action );
    if ( '' === $action ) {
      return (bool) $allow_empty;
    }

    if ( in_array( $action, array( 'heartbeat' ), true ) ) {
      return false;
    }

    $write_tokens = array(
      'save',
      'update',
      'delete',
      'trash',
      'publish',
      'create',
      'edit',
      'insert',
      'remove',
      'activate',
      'deactivate',
      'dismiss',
      'duplicate',
      'import',
      'export',
      'install',
      'reset',
      'submit',
      'inline-save',
      'inline-save-tax',
    );

    foreach ( $write_tokens as $token ) {
      if ( false !== strpos( $action, $token ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Log a blocked demo-lock write event with throttling.
   *
   * @param object $rule    Rule object.
   * @param int    $user_id User ID.
   * @param string $channel Block channel.
   * @param array  $meta    Extra meta.
   * @return void
   */
  private function log_block_event( $rule, $user_id, $channel, $meta = array() ) {
    $rule_id = isset( $rule->id ) ? absint( $rule->id ) : 0;
    $user_id = absint( $user_id );
    $channel = sanitize_key( (string) $channel );

    if ( ! $rule_id || ! $user_id || '' === $channel ) {
      return;
    }

    $transient_key = 'psh_dml_' . md5( $rule_id . ':' . $user_id . ':' . $channel );
    if ( get_transient( $transient_key ) ) {
      return;
    }

    set_transient( $transient_key, 1, 5 * MINUTE_IN_SECONDS );

    Polanger_Shield_Activity::log(
      'shield_demo_lock_blocked',
      array(
        'object_type' => 'shield_rule',
        'object_id'   => $rule_id,
        'meta'        => array_merge(
          array(
            'rule_type' => 'demo_lock',
            'channel'   => $channel,
          ),
          $meta
        ),
      )
    );
  }

  /**
   * Normalized request method.
   *
   * @return string
   */
  private function get_request_method() {
    return isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
  }
}
