<?php
/**
 * Frontend content visibility manager.
 *
 * @package Polanger_Dashboard_Tools
 */

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

require_once PDT_PLUGIN_DIR . 'includes/class-content-visibility-meta-box.php';
require_once PDT_PLUGIN_DIR . 'includes/class-content-visibility-guard.php';
require_once PDT_PLUGIN_DIR . 'includes/class-content-visibility-query.php';
require_once PDT_PLUGIN_DIR . 'includes/class-content-visibility-rest.php';
require_once PDT_PLUGIN_DIR . 'includes/class-content-visibility-sitemaps.php';

class PDT_Content_Visibility {

  const META_MODE           = '_pdt_content_visibility_mode';
  const META_ROLES          = '_pdt_content_visibility_roles';
  const META_BEHAVIOR       = '_pdt_content_visibility_denied_behavior';
  const META_COMPATIBILITY  = '_pdt_content_visibility_compatibility';
  const META_REDIRECT_URL   = '_pdt_content_visibility_redirect_url';
  const META_MESSAGE        = '_pdt_content_visibility_message';

  /**
   * Boot feature components.
   */
  public function __construct() {
    add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );

    if ( ! self::is_enabled() ) {
      return;
    }

    add_action( 'init', array( $this, 'register_post_meta_definitions' ) );

    $meta_box = new PDT_Content_Visibility_Meta_Box();
    $guard    = new PDT_Content_Visibility_Guard();
    $query    = new PDT_Content_Visibility_Query();
    $rest     = new PDT_Content_Visibility_REST();
    $sitemap  = new PDT_Content_Visibility_Sitemaps();

    $meta_box->register_hooks();
    $guard->register_hooks();
    $query->register_hooks();
    $rest->register_hooks();
    $sitemap->register_hooks();
  }

  /**
   * Register post meta used by classic and block editors.
   *
   * @return void
   */
  public function register_post_meta_definitions() {
    register_meta(
      'post',
      self::META_MODE,
      array(
        'type'              => 'string',
        'single'            => true,
        'default'           => 'public',
        'sanitize_callback' => array( __CLASS__, 'sanitize_visibility_mode' ),
        'auth_callback'     => array( __CLASS__, 'auth_post_meta' ),
        'show_in_rest'      => array(
          'schema' => array(
            'type'    => 'string',
            'default' => 'public',
          ),
        ),
      )
    );

    register_meta(
      'post',
      self::META_ROLES,
      array(
        'type'              => 'array',
        'single'            => true,
        'default'           => array(),
        'sanitize_callback' => array( __CLASS__, 'sanitize_roles' ),
        'auth_callback'     => array( __CLASS__, 'auth_post_meta' ),
        'show_in_rest'      => array(
          'schema' => array(
            'type'    => 'array',
            'default' => array(),
            'items'   => array(
              'type' => 'string',
            ),
          ),
        ),
      )
    );

    register_meta(
      'post',
      self::META_BEHAVIOR,
      array(
        'type'              => 'string',
        'single'            => true,
        'default'           => 'inherit',
        'sanitize_callback' => array( __CLASS__, 'sanitize_denied_behavior' ),
        'auth_callback'     => array( __CLASS__, 'auth_post_meta' ),
        'show_in_rest'      => array(
          'schema' => array(
            'type'    => 'string',
            'default' => 'inherit',
          ),
        ),
      )
    );

    register_meta(
      'post',
      self::META_COMPATIBILITY,
      array(
        'type'              => 'string',
        'single'            => true,
        'default'           => 'inherit',
        'sanitize_callback' => array( __CLASS__, 'sanitize_compatibility_mode' ),
        'auth_callback'     => array( __CLASS__, 'auth_post_meta' ),
        'show_in_rest'      => array(
          'schema' => array(
            'type'    => 'string',
            'default' => 'inherit',
          ),
        ),
      )
    );

    register_meta(
      'post',
      self::META_REDIRECT_URL,
      array(
        'type'              => 'string',
        'single'            => true,
        'default'           => '',
        'sanitize_callback' => array( __CLASS__, 'sanitize_redirect_url' ),
        'auth_callback'     => array( __CLASS__, 'auth_post_meta' ),
        'show_in_rest'      => array(
          'schema' => array(
            'type'    => 'string',
            'default' => '',
          ),
        ),
      )
    );

    register_meta(
      'post',
      self::META_MESSAGE,
      array(
        'type'              => 'string',
        'single'            => true,
        'default'           => '',
        'sanitize_callback' => array( __CLASS__, 'sanitize_custom_message' ),
        'auth_callback'     => array( __CLASS__, 'auth_post_meta' ),
        'show_in_rest'      => array(
          'schema' => array(
            'type'    => 'string',
            'default' => '',
          ),
        ),
      )
    );
  }

  /**
   * Enqueue shared admin assets for settings and editor screens.
   *
   * @param string $hook Current admin hook.
   * @return void
   */
  public function enqueue_admin_assets( $hook ) {
    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
    if ( ! $screen ) {
      return;
    }

    $is_settings_page = false;

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen detection.
    if ( isset( $_GET['page'] ) ) {
      // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen detection.
      $is_settings_page = ( 'polanger-settings' === sanitize_text_field( wp_unslash( $_GET['page'] ) ) );
    }

    $is_enabled          = self::is_enabled();
    $is_supported_editor = $is_enabled && in_array( $hook, array( 'post.php', 'post-new.php' ), true ) && in_array( $screen->post_type, self::get_supported_post_types(), true );

    if ( ! $is_settings_page && ! $is_supported_editor ) {
      return;
    }

    wp_enqueue_style(
      'pdt-content-visibility-admin',
      plugins_url( 'assets/css/content-visibility-admin.css', PDT_PLUGIN_FILE ),
      array(),
      PDT_VERSION
    );

    if ( function_exists( 'wp_enqueue_editor' ) ) {
      wp_enqueue_editor();
    }

    if ( $is_supported_editor ) {
      wp_enqueue_script(
        'pdt-content-visibility-classic',
        plugins_url( 'assets/js/content-visibility-classic.js', PDT_PLUGIN_FILE ),
        array(),
        PDT_VERSION,
        true
      );
    }
  }

  /**
   * Shared general settings defaults.
   *
   * @return array
   */
  public static function get_default_general_settings() {
    return array(
      'content_visibility_enabled'                 => false,
      'content_visibility_default_denied_behavior' => 'message',
      'content_visibility_default_compatibility'   => 'hard',
      'content_visibility_default_redirect_url'    => '',
      'content_visibility_default_message'         => '',
      'content_visibility_hide_from_archives'      => true,
      'content_visibility_hide_from_rest'          => true,
      'content_visibility_hide_from_sitemaps'      => true,
    );
  }

  /**
   * Return effective feature settings.
   *
   * @return array
   */
  public static function get_general_settings() {
    global $pdt_settings_page;

    if ( $pdt_settings_page instanceof PDT_Settings_Page ) {
      $settings = $pdt_settings_page->get_settings();
    } else {
      $settings = get_option( 'pdt_general_settings', array() );
    }

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

    return wp_parse_args( $settings, self::get_default_general_settings() );
  }

  /**
   * Whether Frontend Content Visibility is enabled.
   *
   * @return bool
   */
  public static function is_enabled() {
    $settings = self::get_general_settings();

    return ! empty( $settings['content_visibility_enabled'] );
  }

  /**
   * Sanitize Frontend Content Visibility settings from the Settings tab form.
   *
   * @param array $settings Existing settings.
   * @param array $request  Raw request payload.
   * @return array
   */
  public static function sanitize_settings( $settings, $request ) {
    $settings = is_array( $settings ) ? $settings : array();
    $request  = is_array( $request ) ? $request : array();

    $settings['content_visibility_enabled'] = isset( $request['content_visibility_enabled'] );

    $behavior = isset( $request['content_visibility_default_denied_behavior'] )
      ? self::sanitize_denied_behavior( wp_unslash( $request['content_visibility_default_denied_behavior'] ) )
      : 'message';

    if ( 'inherit' === $behavior ) {
      $behavior = 'message';
    }

    $compatibility = isset( $request['content_visibility_default_compatibility'] )
      ? self::sanitize_compatibility_mode( wp_unslash( $request['content_visibility_default_compatibility'] ) )
      : 'hard';

    if ( 'inherit' === $compatibility ) {
      $compatibility = 'hard';
    }

    $settings['content_visibility_default_denied_behavior'] = $behavior;
    $settings['content_visibility_default_compatibility']   = $compatibility;
    $settings['content_visibility_default_redirect_url']    = isset( $request['content_visibility_default_redirect_url'] )
      ? self::sanitize_redirect_url( wp_unslash( $request['content_visibility_default_redirect_url'] ) )
      : '';
    $settings['content_visibility_default_message']         = isset( $request['content_visibility_default_message'] )
      ? self::sanitize_custom_message( wp_unslash( $request['content_visibility_default_message'] ) )
      : '';
    $settings['content_visibility_hide_from_archives']      = isset( $request['content_visibility_hide_from_archives'] );
    $settings['content_visibility_hide_from_rest']          = isset( $request['content_visibility_hide_from_rest'] );
    $settings['content_visibility_hide_from_sitemaps']      = isset( $request['content_visibility_hide_from_sitemaps'] );

    return $settings;
  }

  /**
   * Backward-compatible wrapper for older General-tab integrations.
   *
   * @param array $settings Existing settings.
   * @param array $request  Raw request payload.
   * @return array
   */
  public static function sanitize_general_settings( $settings, $request ) {
    return self::sanitize_settings( $settings, $request );
  }

  /**
   * Render the Frontend Content Visibility settings section.
   *
   * @param array $settings Current settings.
   * @return void
   */
  public static function render_settings_section( $settings ) {
    $settings               = wp_parse_args( is_array( $settings ) ? $settings : array(), self::get_default_general_settings() );
    $behavior_options       = self::get_denied_behavior_options();
    $compatibility_options  = self::get_compatibility_mode_options();
    $is_enabled             = ! empty( $settings['content_visibility_enabled'] );
    ?>
    <div id="pdt-content-visibility-settings" class="pdt-settings-section pdt-content-visibility-settings">
      <h2><?php esc_html_e( 'Frontend Content Visibility', 'polanger-admin-menu-manager' ); ?></h2>
      <p class="description"><?php esc_html_e( 'Manage how restricted posts, pages, and custom post types behave on the public site. Individual rules are set from the Content Visibility panel on each content edit screen.', 'polanger-admin-menu-manager' ); ?></p>

      <div class="pdt-form-row">
        <label class="pdt-toggle-switch">
          <input type="checkbox" name="content_visibility_enabled" value="1" <?php checked( $is_enabled ); ?> id="content_visibility_enabled" />
          <span class="pdt-toggle-slider"></span>
          <span class="pdt-toggle-label"><?php esc_html_e( 'Enable Frontend Content Visibility', 'polanger-admin-menu-manager' ); ?></span>
        </label>
        <p class="description"><?php esc_html_e( 'When disabled, no frontend visibility guards, query filters, REST masking, sitemap filtering, or editor visibility controls are loaded.', 'polanger-admin-menu-manager' ); ?></p>
      </div>

      <div id="pdt-content-visibility-options" class="pdt-content-visibility-options<?php echo $is_enabled ? ' is-visible' : ''; ?>">
        <div class="pdt-info-notice">
          <span class="dashicons dashicons-lock"></span>
          <div>
            <strong><?php esc_html_e( 'How this works', 'polanger-admin-menu-manager' ); ?></strong>
            <p><?php esc_html_e( 'Editors choose role-based visibility per content item. These settings define the default denied behavior, compatibility mode, and whether restricted content should also disappear from archives, search results, REST responses, and WordPress sitemaps.', 'polanger-admin-menu-manager' ); ?></p>
          </div>
        </div>

        <div class="pdt-form-row">
          <label for="content_visibility_default_denied_behavior"><?php esc_html_e( 'Default denied behavior', 'polanger-admin-menu-manager' ); ?></label>
          <p class="description"><?php esc_html_e( 'Choose what happens when a visitor reaches protected content and no per-content override is set.', 'polanger-admin-menu-manager' ); ?></p>
          <select name="content_visibility_default_denied_behavior" id="content_visibility_default_denied_behavior">
            <?php foreach ( $behavior_options as $value => $label ) : ?>
              <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $settings['content_visibility_default_denied_behavior'], $value ); ?>>
                <?php echo esc_html( $label ); ?>
              </option>
            <?php endforeach; ?>
          </select>
        </div>

        <div class="pdt-form-row pdt-content-visibility-default-message-row">
          <label for="content_visibility_default_message"><?php esc_html_e( 'Default access denied message', 'polanger-admin-menu-manager' ); ?></label>
          <p class="description"><?php esc_html_e( 'Shown when the denied behavior resolves to "Show access denied message". HTML and shortcodes are supported. Leave empty to use the built-in default message.', 'polanger-admin-menu-manager' ); ?></p>
          <?php
          wp_editor(
            $settings['content_visibility_default_message'],
            'content_visibility_default_message_editor',
            array(
              'textarea_name' => 'content_visibility_default_message',
              'textarea_rows' => 6,
              'media_buttons' => false,
              'teeny'         => true,
              'quicktags'     => true,
              'tinymce'       => array(
                'toolbar1' => 'bold,italic,bullist,numlist,link,unlink,undo,redo',
                'wpautop'  => true,
              ),
            )
          );
          ?>
        </div>

        <div class="pdt-form-row pdt-content-visibility-default-redirect-row" <?php echo ( 'custom_redirect' === $settings['content_visibility_default_denied_behavior'] ) ? '' : 'style="display:none;"'; ?>>
          <label for="content_visibility_default_redirect_url"><?php esc_html_e( 'Default redirect URL', 'polanger-admin-menu-manager' ); ?></label>
          <p class="description"><?php esc_html_e( 'Used when the default or per-content denied behavior is set to custom redirect. Leave empty to fall back to the homepage.', 'polanger-admin-menu-manager' ); ?></p>
          <input
            type="url"
            class="regular-text"
            name="content_visibility_default_redirect_url"
            id="content_visibility_default_redirect_url"
            value="<?php echo esc_attr( $settings['content_visibility_default_redirect_url'] ); ?>"
            placeholder="<?php echo esc_attr( home_url( '/members-only/' ) ); ?>"
          />
        </div>

        <div class="pdt-form-row">
          <label for="content_visibility_default_compatibility"><?php esc_html_e( 'Default compatibility mode', 'polanger-admin-menu-manager' ); ?></label>
          <p class="description"><?php esc_html_e( 'Theme-friendly replacement keeps the active theme layout and swaps only the main content area. Full access denied page is stricter and prevents builders or theme wrappers from showing protected output.', 'polanger-admin-menu-manager' ); ?></p>
          <select name="content_visibility_default_compatibility" id="content_visibility_default_compatibility">
            <?php foreach ( $compatibility_options as $value => $label ) : ?>
              <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $settings['content_visibility_default_compatibility'], $value ); ?>>
                <?php echo esc_html( $label ); ?>
              </option>
            <?php endforeach; ?>
          </select>
        </div>

        <div class="pdt-form-row">
          <label class="pdt-toggle-switch">
            <input type="checkbox" name="content_visibility_hide_from_archives" value="1" <?php checked( ! empty( $settings['content_visibility_hide_from_archives'] ) ); ?> />
            <span class="pdt-toggle-slider"></span>
            <span class="pdt-toggle-label"><?php esc_html_e( 'Hide restricted content from archives, search results, feeds, and standard frontend loops', 'polanger-admin-menu-manager' ); ?></span>
          </label>
        </div>

        <div class="pdt-form-row">
          <label class="pdt-toggle-switch">
            <input type="checkbox" name="content_visibility_hide_from_rest" value="1" <?php checked( ! empty( $settings['content_visibility_hide_from_rest'] ) ); ?> />
            <span class="pdt-toggle-slider"></span>
            <span class="pdt-toggle-label"><?php esc_html_e( 'Hide restricted content from the public REST API', 'polanger-admin-menu-manager' ); ?></span>
          </label>
        </div>

        <div class="pdt-form-row">
          <label class="pdt-toggle-switch">
            <input type="checkbox" name="content_visibility_hide_from_sitemaps" value="1" <?php checked( ! empty( $settings['content_visibility_hide_from_sitemaps'] ) ); ?> />
            <span class="pdt-toggle-slider"></span>
            <span class="pdt-toggle-label"><?php esc_html_e( 'Hide restricted content from WordPress XML sitemaps', 'polanger-admin-menu-manager' ); ?></span>
          </label>
        </div>
      </div>
    </div>
    <?php
  }

  /**
   * Backward-compatible wrapper for older General-tab rendering.
   *
   * @param array $settings Current settings.
   * @return void
   */
  public static function render_general_settings_section( $settings ) {
    self::render_settings_section( $settings );
  }

  /**
   * Whether archives and search queries should exclude restricted content.
   *
   * @return bool
   */
  public static function should_hide_from_archives() {
    if ( ! self::is_enabled() ) {
      return false;
    }

    $settings = self::get_general_settings();
    return ! empty( $settings['content_visibility_hide_from_archives'] );
  }

  /**
   * Whether restricted content should be masked from REST responses.
   *
   * @return bool
   */
  public static function should_hide_from_rest() {
    if ( ! self::is_enabled() ) {
      return false;
    }

    $settings = self::get_general_settings();
    return ! empty( $settings['content_visibility_hide_from_rest'] );
  }

  /**
   * Whether restricted content should be removed from XML sitemaps.
   *
   * @return bool
   */
  public static function should_hide_from_sitemaps() {
    if ( ! self::is_enabled() ) {
      return false;
    }

    $settings = self::get_general_settings();
    return ! empty( $settings['content_visibility_hide_from_sitemaps'] );
  }

  /**
   * Return supported public post types.
   *
   * @return array
   */
  public static function get_supported_post_types() {
    static $supported_cache = null;

    if ( null !== $supported_cache ) {
      return $supported_cache;
    }

    $post_types = get_post_types(
      array(
        'public'  => true,
        'show_ui' => true,
      ),
      'objects'
    );

    $supported = array();
    $excluded  = array(
      'attachment',
      'custom_css',
      'customize_changeset',
      'wp_block',
      'wp_navigation',
      'wp_template',
      'wp_template_part',
    );

    foreach ( $post_types as $post_type => $object ) {
      if ( in_array( $post_type, $excluded, true ) ) {
        continue;
      }

      if ( empty( $object->show_ui ) || empty( $object->public ) ) {
        continue;
      }

      $supported[] = $post_type;
    }

    /**
     * Filter post types that may use frontend content visibility rules.
     *
     * @param string[] $supported Post type slugs.
     */
    $supported_cache = array_values( array_unique( apply_filters( 'pdt_content_visibility_post_types', $supported ) ) );

    return $supported_cache;
  }

  /**
   * Whether a post ID belongs to a supported post type.
   *
   * @param int $post_id Post ID.
   * @return bool
   */
  public static function is_supported_post( $post_id ) {
    $post = get_post( $post_id );

    if ( ! $post ) {
      return false;
    }

    return in_array( $post->post_type, self::get_supported_post_types(), true );
  }

  /**
   * Return available frontend visibility modes.
   *
   * @return array
   */
  public static function get_visibility_mode_options() {
    return array(
      'public'         => __( 'Public', 'polanger-admin-menu-manager' ),
      'logged_in_only' => __( 'Logged-in users only', 'polanger-admin-menu-manager' ),
      'show_roles'     => __( 'Show only selected roles', 'polanger-admin-menu-manager' ),
      'hide_roles'     => __( 'Hide from selected roles', 'polanger-admin-menu-manager' ),
    );
  }

  /**
   * Return editor-facing visibility modes for new rules.
   *
   * The legacy allowlist mode is intentionally hidden from new selections to
   * keep the interface focused on the most common "hide from these roles"
   * workflow. If an existing rule still uses the legacy mode, it is surfaced
   * with a clear label so the current state remains editable.
   *
   * @param string $current_mode Currently saved mode.
   * @return array
   */
  public static function get_editor_visibility_mode_options( $current_mode = 'public' ) {
    $options = array(
      'public'         => __( 'Public', 'polanger-admin-menu-manager' ),
      'logged_in_only' => __( 'Logged-in users only', 'polanger-admin-menu-manager' ),
      'hide_roles'     => __( 'Hide from selected roles', 'polanger-admin-menu-manager' ),
    );

    if ( 'show_roles' === sanitize_key( (string) $current_mode ) ) {
      $options['show_roles'] = __( 'Legacy: Show only selected roles', 'polanger-admin-menu-manager' );
    }

    return $options;
  }

  /**
   * Return denied behavior choices.
   *
   * @param bool $include_inherit Whether to include the "inherit" option.
   * @return array
   */
  public static function get_denied_behavior_options( $include_inherit = false ) {
    $options = array();

    if ( $include_inherit ) {
      $options['inherit'] = __( 'Use global setting', 'polanger-admin-menu-manager' );
    }

    $options['login']           = __( 'Redirect to login', 'polanger-admin-menu-manager' );
    $options['404']             = __( 'Show 404', 'polanger-admin-menu-manager' );
    $options['message']         = __( 'Show access denied message', 'polanger-admin-menu-manager' );
    $options['custom_redirect'] = __( 'Redirect to custom URL', 'polanger-admin-menu-manager' );

    return $options;
  }

  /**
   * Return compatibility mode choices.
   *
   * @param bool $include_inherit Whether to include the "inherit" option.
   * @return array
   */
  public static function get_compatibility_mode_options( $include_inherit = false ) {
    $options = array();

    if ( $include_inherit ) {
      $options['inherit'] = __( 'Use global setting', 'polanger-admin-menu-manager' );
    }

    $options['soft'] = __( 'Theme-friendly replacement', 'polanger-admin-menu-manager' );
    $options['hard'] = __( 'Force full access denied page', 'polanger-admin-menu-manager' );

    return $options;
  }

  /**
   * Return role labels as slug => label pairs.
   *
   * @return array
   */
  public static function get_role_labels() {
    $roles = wp_roles();
    return $roles instanceof WP_Roles ? $roles->get_names() : array();
  }

  /**
   * Return editor-friendly role choices.
   *
   * @return array
   */
  public static function get_role_choices() {
    $choices = array();

    foreach ( self::get_role_labels() as $role_slug => $role_label ) {
      $choices[] = array(
        'value' => $role_slug,
        'label' => $role_label,
      );
    }

    return $choices;
  }

  /**
   * Normalize one rule array.
   *
   * @param array $rule Raw rule values.
   * @return array
   */
  public static function normalize_rule( $rule ) {
    $rule = is_array( $rule ) ? $rule : array();

    $normalized = wp_parse_args(
      $rule,
      array(
        'mode'          => 'public',
        'roles'         => array(),
        'denied'        => 'inherit',
        'compatibility' => 'inherit',
        'redirect_url'  => '',
        'message'       => '',
      )
    );

    $normalized['mode']          = self::sanitize_visibility_mode( $normalized['mode'] );
    $normalized['roles']         = self::sanitize_roles( $normalized['roles'] );
    $normalized['denied']        = self::sanitize_denied_behavior( $normalized['denied'] );
    $normalized['compatibility'] = self::sanitize_compatibility_mode( $normalized['compatibility'] );
    $normalized['redirect_url']  = self::sanitize_redirect_url( $normalized['redirect_url'] );
    $normalized['message']       = self::sanitize_custom_message( $normalized['message'] );

    if ( in_array( $normalized['mode'], array( 'show_roles', 'hide_roles' ), true ) && empty( $normalized['roles'] ) ) {
      $normalized['mode'] = 'public';
    }

    return $normalized;
  }

  /**
   * Return one post rule.
   *
   * @param int $post_id Post ID.
   * @return array
   */
  public static function get_post_rule( $post_id ) {
    $post_id = absint( $post_id );

    if ( ! $post_id || ! self::is_supported_post( $post_id ) ) {
      return self::normalize_rule( array() );
    }

    return self::normalize_rule(
      array(
        'mode'          => get_post_meta( $post_id, self::META_MODE, true ),
        'roles'         => get_post_meta( $post_id, self::META_ROLES, true ),
        'denied'        => get_post_meta( $post_id, self::META_BEHAVIOR, true ),
        'compatibility' => get_post_meta( $post_id, self::META_COMPATIBILITY, true ),
        'redirect_url'  => get_post_meta( $post_id, self::META_REDIRECT_URL, true ),
        'message'       => get_post_meta( $post_id, self::META_MESSAGE, true ),
      )
    );
  }

  /**
   * Persist one rule to post meta.
   *
   * @param int   $post_id Post ID.
   * @param array $rule    Normalized rule.
   * @return void
   */
  public static function save_post_rule( $post_id, $rule ) {
    $post_id = absint( $post_id );
    $rule    = self::normalize_rule( $rule );

    if ( ! $post_id || ! self::is_supported_post( $post_id ) ) {
      return;
    }

    if ( 'public' === $rule['mode'] && empty( $rule['roles'] ) && 'inherit' === $rule['denied'] && 'inherit' === $rule['compatibility'] && '' === $rule['redirect_url'] && '' === $rule['message'] ) {
      delete_post_meta( $post_id, self::META_MODE );
      delete_post_meta( $post_id, self::META_ROLES );
      delete_post_meta( $post_id, self::META_BEHAVIOR );
      delete_post_meta( $post_id, self::META_COMPATIBILITY );
      delete_post_meta( $post_id, self::META_REDIRECT_URL );
      delete_post_meta( $post_id, self::META_MESSAGE );
      return;
    }

    update_post_meta( $post_id, self::META_MODE, $rule['mode'] );
    update_post_meta( $post_id, self::META_ROLES, $rule['roles'] );
    update_post_meta( $post_id, self::META_BEHAVIOR, $rule['denied'] );
    update_post_meta( $post_id, self::META_COMPATIBILITY, $rule['compatibility'] );
    update_post_meta( $post_id, self::META_REDIRECT_URL, $rule['redirect_url'] );
    update_post_meta( $post_id, self::META_MESSAGE, $rule['message'] );
  }

  /**
   * Resolve whether a user may view a post on the frontend.
   *
   * @param int      $post_id Post ID.
   * @param int|null $user_id Optional user ID. Defaults to current user.
   * @return bool
   */
  public static function can_user_view_post( $post_id, $user_id = null ) {
    if ( ! self::is_enabled() ) {
      return true;
    }

    $post_id = absint( $post_id );

    if ( ! $post_id || ! self::is_supported_post( $post_id ) ) {
      return true;
    }

    $rule = self::get_post_rule( $post_id );

    if ( 'public' === $rule['mode'] ) {
      return true;
    }

    if ( null === $user_id ) {
      $user_id = get_current_user_id();
    }

    $user_id   = absint( $user_id );
    $is_logged = $user_id > 0;
    $user      = $is_logged ? get_userdata( $user_id ) : null;
    $roles     = ( $user instanceof WP_User ) ? array_values( array_unique( array_map( 'sanitize_key', $user->roles ) ) ) : array();

    switch ( $rule['mode'] ) {
      case 'logged_in_only':
        return $is_logged;

      case 'show_roles':
        if ( ! $is_logged || empty( $roles ) ) {
          return false;
        }

        return ! empty( array_intersect( $roles, $rule['roles'] ) );

      case 'hide_roles':
        if ( empty( $roles ) ) {
          return true;
        }

        return empty( array_intersect( $roles, $rule['roles'] ) );

      case 'public':
      default:
        return true;
    }
  }

  /**
   * Determine whether the current request is a valid frontend preview for a post.
   *
   * @param int $post_id Post ID.
   * @return bool
   */
  public static function current_user_can_preview_post( $post_id ) {
    $post_id = absint( $post_id );

    if ( ! $post_id || ! is_user_logged_in() || ! current_user_can( 'edit_post', $post_id ) || ! is_preview() ) {
      return false;
    }

    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Preview nonce is verified below when present.
    $preview_id = isset( $_GET['preview_id'] ) ? absint( wp_unslash( $_GET['preview_id'] ) ) : 0;
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Preview nonce is verified below when present.
    $preview_nonce = isset( $_GET['preview_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['preview_nonce'] ) ) : '';

    if ( $preview_id && $preview_nonce ) {
      return $preview_id === $post_id && wp_verify_nonce( $preview_nonce, 'post_preview_' . $post_id );
    }

    return true;
  }

  /**
   * Merge frontend visibility constraints into a WP_Query args array.
   *
   * @param array    $query_args Query args.
   * @param int|null $user_id    Optional user ID. Defaults to current user.
   * @return array
   */
  public static function append_visibility_query_args( $query_args, $user_id = null ) {
    if ( ! self::is_enabled() ) {
      return is_array( $query_args ) ? $query_args : array();
    }

    $query_args       = is_array( $query_args ) ? $query_args : array();
    $visibility_query = self::get_visible_content_meta_query( $user_id );

    if ( empty( $visibility_query ) ) {
      return $query_args;
    }

    if ( self::has_meta_query_clauses( $query_args['meta_query'] ?? array() ) ) {
      $query_args['meta_query'] = array(
        'relation' => 'AND',
        $query_args['meta_query'],
        $visibility_query,
      );
    } else {
      $query_args['meta_query'] = $visibility_query;
    }

    return $query_args;
  }

  /**
   * Determine whether one meta_query array contains real clauses.
   *
   * @param mixed $meta_query Meta query candidate.
   * @return bool
   */
  private static function has_meta_query_clauses( $meta_query ) {
    if ( ! is_array( $meta_query ) ) {
      return false;
    }

    foreach ( $meta_query as $key => $clause ) {
      if ( 'relation' === $key ) {
        continue;
      }

      if ( is_array( $clause ) && ! empty( $clause ) ) {
        return true;
      }
    }

    return false;
  }

  /**
   * Build a meta_query that returns only content visible to one user context.
   *
   * @param int|null $user_id Optional user ID. Defaults to current user.
   * @return array
   */
  public static function get_visible_content_meta_query( $user_id = null ) {
    if ( ! self::is_enabled() ) {
      return array();
    }

    if ( null === $user_id ) {
      $user_id = get_current_user_id();
    }

    $user_id   = absint( $user_id );
    $user_roles = self::get_user_role_slugs( $user_id );
    $meta_query = array(
      'relation' => 'OR',
      array(
        'key'     => self::META_MODE,
        'compare' => 'NOT EXISTS',
      ),
      array(
        'key'   => self::META_MODE,
        'value' => 'public',
      ),
    );

    if ( $user_id > 0 ) {
      $meta_query[] = array(
        'key'   => self::META_MODE,
        'value' => 'logged_in_only',
      );
    }

    if ( empty( $user_roles ) ) {
      $meta_query[] = array(
        'key'   => self::META_MODE,
        'value' => 'hide_roles',
      );

      return $meta_query;
    }

    $meta_query[] = array(
      'relation' => 'AND',
      array(
        'key'   => self::META_MODE,
        'value' => 'show_roles',
      ),
      self::get_role_meta_like_query( $user_roles, 'LIKE', 'OR' ),
    );

    $meta_query[] = array(
      'relation' => 'AND',
      array(
        'key'   => self::META_MODE,
        'value' => 'hide_roles',
      ),
      array(
        'relation' => 'OR',
        array(
          'key'     => self::META_ROLES,
          'compare' => 'NOT EXISTS',
        ),
        self::get_role_meta_like_query( $user_roles, 'NOT LIKE', 'AND' ),
      ),
    );

    return $meta_query;
  }

  /**
   * Resolve the effective denied behavior for one post.
   *
   * @param int $post_id Post ID.
   * @return string
   */
  public static function resolve_denied_behavior( $post_id ) {
    $rule      = self::get_post_rule( $post_id );
    $settings  = self::get_general_settings();
    $behavior  = ( 'inherit' !== $rule['denied'] ) ? $rule['denied'] : $settings['content_visibility_default_denied_behavior'];
    $available = self::get_denied_behavior_options();

    return isset( $available[ $behavior ] ) ? $behavior : 'message';
  }

  /**
   * Resolve the effective compatibility mode for one post.
   *
   * @param int $post_id Post ID.
   * @return string
   */
  public static function resolve_compatibility_mode( $post_id ) {
    $rule          = self::get_post_rule( $post_id );
    $settings      = self::get_general_settings();
    $compatibility = ( 'inherit' !== $rule['compatibility'] ) ? $rule['compatibility'] : $settings['content_visibility_default_compatibility'];
    $available     = self::get_compatibility_mode_options();

    return isset( $available[ $compatibility ] ) ? $compatibility : 'hard';
  }

  /**
   * Return the built-in fallback access denied message.
   *
   * @return string
   */
  public static function get_default_denied_message() {
    return __( 'You must sign in with an authorized account to view this content.', 'polanger-admin-menu-manager' );
  }

  /**
   * Render one access denied message with safe formatting and shortcode support.
   *
   * @param string $message Raw message content.
   * @return string
   */
  public static function render_denied_message_markup( $message ) {
    $message = self::sanitize_custom_message( $message );

    if ( '' === $message ) {
      $message = self::get_default_denied_message();
    }

    return trim( wpautop( do_shortcode( $message ) ) );
  }

  /**
   * Return the admin URL for the global Frontend Content Visibility settings.
   *
   * @return string
   */
  public static function get_default_settings_url() {
    return admin_url( 'admin.php?page=polanger-settings&tab=content-visibility' );
  }

  /**
   * Resolve the effective access denied message for one post.
   *
   * @param int $post_id Post ID.
   * @return string
   */
  public static function resolve_denied_message( $post_id ) {
    $rule     = self::get_post_rule( $post_id );
    $settings = self::get_general_settings();

    if ( ! empty( $rule['message'] ) ) {
      return $rule['message'];
    }

    if ( ! empty( $settings['content_visibility_default_message'] ) ) {
      return $settings['content_visibility_default_message'];
    }

    return self::get_default_denied_message();
  }

  /**
   * Resolve the redirect target for a denied request.
   *
   * @param int $post_id Post ID.
   * @return string
   */
  public static function resolve_redirect_url( $post_id ) {
    $rule     = self::get_post_rule( $post_id );
    $settings = self::get_general_settings();
    $target   = '';

    if ( ! empty( $rule['redirect_url'] ) ) {
      $target = $rule['redirect_url'];
    } elseif ( ! empty( $settings['content_visibility_default_redirect_url'] ) ) {
      $target = $settings['content_visibility_default_redirect_url'];
    }

    $target = trim( $target );

    if ( '' === $target ) {
      return home_url( '/' );
    }

    return wp_validate_redirect( $target, home_url( '/' ) );
  }

  /**
   * Build access denied messaging context.
   *
   * @param int $post_id Post ID.
   * @return array
   */
  public static function get_access_denied_context( $post_id ) {
    return array(
      'title'    => __( 'Access Restricted', 'polanger-admin-menu-manager' ),
      'message'  => self::resolve_denied_message( $post_id ),
      'home_url' => home_url( '/' ),
    );
  }

  /**
   * Render the theme-friendly denial notice.
   *
   * @param int $post_id Post ID.
   * @return string
   */
  public static function render_access_denied_notice( $post_id ) {
    $context = self::get_access_denied_context( $post_id );

    ob_start();
    ?>
    <div class="pdt-content-visibility-notice" role="alert">
      <div class="pdt-content-visibility-notice__eyebrow"><?php esc_html_e( 'Protected Content', 'polanger-admin-menu-manager' ); ?></div>
      <h2 class="pdt-content-visibility-notice__title"><?php echo esc_html( $context['title'] ); ?></h2>
      <div class="pdt-content-visibility-notice__message"><?php echo self::render_denied_message_markup( $context['message'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML and shortcodes are sanitized before rendering. ?></div>
      <div class="pdt-content-visibility-notice__actions">
        <a class="pdt-content-visibility-button pdt-content-visibility-button--primary" href="<?php echo esc_url( $context['home_url'] ); ?>">
          <?php esc_html_e( 'Go to homepage', 'polanger-admin-menu-manager' ); ?>
        </a>
      </div>
    </div>
    <?php

    return (string) ob_get_clean();
  }

  /**
   * Return post IDs currently restricted for the active user context.
   *
   * @param array    $post_types Supported post types to scan.
   * @param int|null $user_id    Optional user ID. Pass 0 for guest context.
   * @return array
   */
  public static function get_restricted_post_ids( $post_types = array(), $user_id = null ) {
    static $cache = array();

    if ( ! self::is_enabled() ) {
      return array();
    }

    if ( empty( $post_types ) ) {
      $post_types = self::get_supported_post_types();
    }

    $post_types = array_values( array_intersect( array_map( 'sanitize_key', (array) $post_types ), self::get_supported_post_types() ) );
    sort( $post_types );

    if ( empty( $post_types ) ) {
      return array();
    }

    if ( null === $user_id ) {
      $user_id = get_current_user_id();
    }

    $user_id  = absint( $user_id );
    $user     = $user_id ? get_userdata( $user_id ) : null;
    $role_set = ( $user instanceof WP_User ) ? array_values( array_unique( array_map( 'sanitize_key', $user->roles ) ) ) : array();
    sort( $role_set );

    $cache_key = md5(
      wp_json_encode(
        array(
          'post_types' => $post_types,
          'logged_in'  => $user_id > 0,
          'roles'      => $role_set,
        )
      )
    );

    if ( isset( $cache[ $cache_key ] ) ) {
      return $cache[ $cache_key ];
    }

    $candidate_ids = get_posts(
      array(
        'post_type'              => $post_types,
        'post_status'            => 'publish',
        'fields'                 => 'ids',
        'posts_per_page'         => -1,
        'orderby'                => 'ID',
        'order'                  => 'ASC',
        'no_found_rows'          => true,
        'ignore_sticky_posts'    => true,
        'suppress_filters'       => true,
        'update_post_meta_cache' => false,
        'update_post_term_cache' => false,
        'cache_results'          => false,
        'meta_query'             => array(
          array(
            'key'     => self::META_MODE,
            'value'   => array( 'logged_in_only', 'show_roles', 'hide_roles' ),
            'compare' => 'IN',
          ),
        ),
        'pdt_content_visibility_skip' => true,
      )
    );

    $restricted_ids = array();

    foreach ( $candidate_ids as $candidate_id ) {
      $candidate_id = absint( $candidate_id );

      if ( ! self::can_user_view_post( $candidate_id, $user_id ) ) {
        $restricted_ids[] = $candidate_id;
      }
    }

    $cache[ $cache_key ] = $restricted_ids;

    return $restricted_ids;
  }

  /**
   * Return sanitized role slugs for a user context.
   *
   * @param int|null $user_id Optional user ID. Defaults to current user.
   * @return array
   */
  private static function get_user_role_slugs( $user_id = null ) {
    if ( null === $user_id ) {
      $user_id = get_current_user_id();
    }

    $user_id = absint( $user_id );
    $user    = $user_id ? get_userdata( $user_id ) : null;

    if ( ! $user instanceof WP_User ) {
      return array();
    }

    return array_values( array_unique( array_map( 'sanitize_key', $user->roles ) ) );
  }

  /**
   * Build LIKE or NOT LIKE clauses against serialized role lists.
   *
   * @param array  $roles    Role slugs.
   * @param string $compare  SQL comparator.
   * @param string $relation Clause relation.
   * @return array
   */
  private static function get_role_meta_like_query( $roles, $compare, $relation ) {
    $roles    = array_values( array_unique( array_map( 'sanitize_key', (array) $roles ) ) );
    $compare  = ( 'NOT LIKE' === strtoupper( $compare ) ) ? 'NOT LIKE' : 'LIKE';
    $relation = ( 'AND' === strtoupper( $relation ) ) ? 'AND' : 'OR';
    $clauses  = array( 'relation' => $relation );

    foreach ( $roles as $role_slug ) {
      $clauses[] = array(
        'key'     => self::META_ROLES,
        'value'   => '"' . $role_slug . '"',
        'compare' => $compare,
      );
    }

    return $clauses;
  }

  /**
   * Sanitize a visibility mode value.
   *
   * @param string $value Raw value.
   * @return string
   */
  public static function sanitize_visibility_mode( $value ) {
    $value   = sanitize_key( (string) $value );
    $allowed = self::get_visibility_mode_options();

    return isset( $allowed[ $value ] ) ? $value : 'public';
  }

  /**
   * Sanitize a denied behavior value.
   *
   * @param string $value Raw value.
   * @return string
   */
  public static function sanitize_denied_behavior( $value ) {
    $value   = sanitize_key( (string) $value );
    $allowed = self::get_denied_behavior_options( true );

    return isset( $allowed[ $value ] ) ? $value : 'inherit';
  }

  /**
   * Sanitize a compatibility mode value.
   *
   * @param string $value Raw value.
   * @return string
   */
  public static function sanitize_compatibility_mode( $value ) {
    $value   = sanitize_key( (string) $value );
    $allowed = self::get_compatibility_mode_options( true );

    return isset( $allowed[ $value ] ) ? $value : 'inherit';
  }

  /**
   * Sanitize a role list.
   *
   * @param mixed $value Raw value.
   * @return array
   */
  public static function sanitize_roles( $value ) {
    $role_labels = self::get_role_labels();
    $roles       = is_array( $value ) ? $value : array();
    $roles       = array_map( 'sanitize_key', $roles );
    $roles       = array_values( array_unique( array_intersect( $roles, array_keys( $role_labels ) ) ) );

    return $roles;
  }

  /**
   * Sanitize redirect targets.
   *
   * @param string $value Raw URL.
   * @return string
   */
  public static function sanitize_redirect_url( $value ) {
    return esc_url_raw( trim( (string) $value ) );
  }

  /**
   * Sanitize a custom access denied message.
   *
   * @param string $value Raw message.
   * @return string
   */
  public static function sanitize_custom_message( $value ) {
    return trim( wp_kses_post( (string) $value ) );
  }

  /**
   * Authorization callback for registered post meta.
   *
   * @param bool   $allowed Whether access is currently allowed.
   * @param string $meta_key Meta key.
   * @param int    $post_id  Post ID.
   * @param int    $user_id  User ID.
   * @return bool
   */
  public static function auth_post_meta( $allowed, $meta_key, $post_id, $user_id ) {
    unset( $allowed, $meta_key );

    $post_id = absint( $post_id );
    $user_id = absint( $user_id );

    return $post_id > 0 && $user_id > 0 && user_can( $user_id, 'edit_post', $post_id );
  }
}
