<?php
/**
 * The base Shortcode class.
 *
 * Contains utility methods, base class for all GV Shortcodes.
 *
 * @package GravityKit\GravityView\Shortcode
 * @since 3.0.0
 */

namespace GravityKit\GravityView\Shortcode;

use GFCommon;
use GravityKitFoundation;
use GVCommon;
use Throwable;
use WP_Error;

/**
 * The base \GV\Shortcode class.
 *
 * Contains some utility methods, base class for all GV Shortcodes.
 */
class Shortcode {
	/**
	 * Cache of all added shortcodes.
	 *
	 * @since 2.47
	 *
	 * @var array
	 */
	private static $shortcodes;

	/**
	 * @var array The default attributes for this shortcode.
	 */
	protected static $defaults = [];

	/**
	 * @var array The parsed attributes of this shortcode.
	 */
	public $atts;

	/**
	 * @var string The parsed name of this shortcode.
	 */
	public $name;

	/**
	 * @var string The parsed content between tags of this shortcode.
	 */
	public $content;

	/**
	 * The WordPress Shortcode API callback for this shortcode.
	 *
	 * @param array  $atts    The attributes passed.
	 * @param string $content The content inside the shortcode.
	 * @param string $tag     The tag.
	 *
	 * @return string The output.
	 */
	public function callback( $atts, $content = '', $tag = '' ) {
		\gravityview()->log->error(
			'[{shortcode}] shortcode {class}::callback method not implemented.',
			array(
				'shortcode' => $this->name,
				'class'     => get_class( $this ),
			)
		);

		return '';
	}

	/**
	 * Pre-processes content to encode problematic attribute values before WordPress parses them.
	 *
	 * This runs before WordPress's do_shortcode() to base64-encode all attribute values
	 * that contain characters which break WP shortcode parsing (like <).
	 *
	 * @internal
	 *
	 * @since 2.47
	 *
	 * @param string $content The content to preprocess.
	 *
	 * @return string The content with encoded attribute values.
	 */
	public static function preprocess_shortcode_attributes( $content ) {
		if ( empty( $content ) || false === strpos( $content, '[' ) ) {
			return $content;
		}

		// Build pattern to match only our registered shortcodes.
		$shortcodes = array_keys( self::$shortcodes );

		if ( empty( $shortcodes ) ) {
			return $content;
		}

		// Create pattern that matches only our shortcodes.
		$shortcode_pattern = implode( '|', array_map( 'preg_quote', $shortcodes ) );

		// Match any attribute that contains problematic characters.
		// We check for <, >, [, ], or & which can break WordPress parsing.
		$pattern = '/\[(' . $shortcode_pattern . ')(\s+[^\]]*?)\]/';

		$content = preg_replace_callback( $pattern, function( $matches ) {
			$shortcode_name = $matches[1];
			$attributes_string = $matches[2];

			// Parse all attributes and encode those with problematic characters.
			$attributes_string = preg_replace_callback(
				'/(\w+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s\]]+))/',
				function( $attr_matches ) {
					$attr_name = $attr_matches[1];

					// Determine which capture group has the value and quote type.
					if ( isset( $attr_matches[2] ) && $attr_matches[2] !== '' ) {
						// Double quoted value.
						$attr_value = $attr_matches[2];

						$quote = '"';
					} elseif ( isset( $attr_matches[3] ) && $attr_matches[3] !== '' ) {
						// Single quoted value
						$attr_value = $attr_matches[3];

						$quote = "'";
					} elseif ( isset( $attr_matches[4] ) && $attr_matches[4] !== '' ) {
						// Unquoted value
						$attr_value = $attr_matches[4];

						$quote = '';
					} else {
						// No value found, skip this attribute
						return $attr_matches[0];
					}

					// Check if value contains problematic characters
					if ( preg_match( '/[<>&\[\]]/', $attr_value ) ) {
						// Base64-encode and prefix with 'b64:'
						$attr_value = 'b64:' . base64_encode( $attr_value );
					}

					return $attr_name . '=' . $quote . $attr_value . $quote;
				},
				$attributes_string
			);

			return '[' . $shortcode_name . $attributes_string . ']';
		}, $content );

		return $content;
	}

	/**
	 * Wrapper callback that's used to normalize attributes and other operations before calling the actual shortcode callback.
	 *
	 * @since 2.47
	 *
	 * @param array  $atts    The attributes passed.
	 * @param string $content The content inside the shortcode.
	 * @param string $tag     The tag.
	 *
	 * @return string The output.
	 */
	public function callback_wrapper( $atts, $content = '', $tag = '' ) {
		// Normalize attributes by decoding any base64-encoded values.
		$atts = $this->normalize_attributes( $atts );

		// Call the actual callback.
		return $this->callback( $atts, $content, $tag );
	}

	/**
	 * Decodes base64-encoded attribute values.
	 *
	 * This method handles base64 encoded values (prefixed with 'b64:') that were
	 * encoded to prevent WordPress shortcode parser from breaking on special characters.
	 *
	 * @since 2.47
	 *
	 * @param array $atts The shortcode attributes.
	 *
	 * @return array The attributes with decoded values.
	 */
	protected function normalize_attributes( $atts ) {
		if ( ! is_array( $atts ) ) {
			return $atts;
		}

		foreach ( $atts as &$value ) {
			if ( is_string( $value ) && strpos( $value, 'b64:' ) === 0 ) {
				$value = base64_decode( substr( $value, 4 ) );
			}
		}

		return $atts;
	}

	/**
	 * Post-processes content after shortcode rendering.
	 *
	 * @internal
	 *
	 * @since 2.47
	 *
	 * @param string $content The content to process.
	 *
	 * @return string The processed content.
	 */
	public static function post_process_content( $content ) {
		if ( empty( $content ) || empty( self::$shortcodes ) ) {
			return $content;
		}

		// Remove secret attribute if the shortcode did not render.
		$shortcodes = array_keys( self::$shortcodes );
		$pattern    = '/\[(' . implode( '|', array_map( 'preg_quote', $shortcodes ) ) . ')([^\]]*)\]/';

		return preg_replace_callback( $pattern, function ( $matches ) {
			$full_match = $matches[0];
			$attributes = $matches[2];

			if ( stripos( $attributes, 'secret=' ) !== false ) {
				$full_match = preg_replace( '/\s*\bsecret\s*=\s*["\']?[^"\'\s\]]+["\']?/', '', $full_match );
			}

			return $full_match;
		}, $content );
	}

	/**
	 * Register this shortcode class with the WordPress Shortcode API.
	 *
	 * @internal
	 *
	 * @since 3.0.0
	 *
	 * @param string $name A shortcode name override. Default: self::$name.
	 *
	 * @return \GV\Shortcode|null The only internally registered instance of this shortcode, or null on error.
	 */
	public static function add( $name = null ) {
		$shortcode = new static();
		$name      = $name ? $name : $shortcode->name;
		if ( shortcode_exists( $name ) ) {
			if ( empty( self::$shortcodes[ $name ] ) ) {
				\gravityview()->log->error( 'Shortcode [{shortcode}] has already been registered elsewhere.', array( 'shortcode' => $name ) );

				return null;
			}
		} else {
			self::$shortcodes[ $name ] = $shortcode;
			add_shortcode( $name, [ $shortcode, 'callback_wrapper' ] );
			add_filter( 'get_the_excerpt', [ $shortcode, 'maybe_strip_shortcode_from_content' ] );

			static $filters_added;

			if ( ! $filters_added ) {
				// Add the content pre-processing filter only once.
				// Use priority 1 to run as early as possible before do_shortcode (priority 11)
				// Use legacy class name string to maintain backwards compatibility with has_filter() checks.
				add_filter( 'the_content', [ 'GV\Shortcode', 'preprocess_shortcode_attributes' ], 1 );
				add_filter( 'widget_text', [ 'GV\Shortcode', 'preprocess_shortcode_attributes' ], 1 );
				add_filter( 'widget_text_content', [ 'GV\Shortcode', 'preprocess_shortcode_attributes' ], 1 );

				// Post-process content after shortcode rendering.
				// Use PHP_INT_MAX to ensure this runs absolutely last
				add_filter( 'the_content', [ 'GV\Shortcode', 'post_process_content' ], PHP_INT_MAX );
				add_filter( 'widget_text', [ 'GV\Shortcode', 'post_process_content' ], PHP_INT_MAX );
				add_filter( 'widget_text_content', [ 'GV\Shortcode', 'post_process_content' ], PHP_INT_MAX );

				$filters_added = true;
			}
		}

		return self::$shortcodes[ $name ];
	}

	/**
	 * Filters the list of shortcode tags to remove from the content.
	 *
	 * @since 2.47
	 *
	 * @internal
	 *
	 * @return array Array of shortcode tags to remove, which is just the current shortcode name.
	 */
	public function _get_strip_shortcode_tagnames() {
		return [ $this->name ];
	}

	/**
	 * Strips the current shortcode from passed content.
	 *
	 * @since 2.47
	 *
	 * @param string $content The content.
	 *
	 * @return string The content with the current shortcode removed.
	 */
	function strip_shortcode_from_content( $content ) {
		add_filter( 'strip_shortcodes_tagnames', [ $this, '_get_strip_shortcode_tagnames' ] );

		$content = strip_shortcodes( $content );

		remove_filter( 'strip_shortcodes_tagnames', [ $this, '_get_strip_shortcode_tagnames' ] );

		return $content;
	}

	/**
	 * Strips the current shortcode from passed content if it exists.
	 *
	 * @since 2.47
	 *
	 * @param string $content The content.
	 *
	 * @return string The content with the current shortcode removed, if it existed. Otherwise, the original content.
	 */
	function maybe_strip_shortcode_from_content( $content ) {
		if( ! has_shortcode( $content, $this->name ) ) {
			return $content;
		}

		return $this->strip_shortcode_from_content( $content );
	}

	/**
	 * Unregister this shortcode.
	 *
	 * @internal
	 *
	 * @return void
	 */
	public static function remove() {
		$shortcode = new static();
		unset( self::$shortcodes[ $shortcode->name ] );
		remove_shortcode( $shortcode->name );
	}

	/**
	 * Parse a string of content and figure out which ones there are.
	 *
	 * Only registered shortcodes (via add_shortcode) will show up.
	 * Returned order is not guaranteed.
	 *
	 * @param string $content Some post content to search through.
	 *
	 * @internal
	 *
	 * @return \GV\Shortcode[] An array of \GV\Shortcode (and subclass) instances.
	 */
	public static function parse( $content ) {
		$shortcodes = array();

		/**
		 * The matches contains:
		 *
		 * 1 - An extra [ to allow for escaping shortcodes with double [[]]
		 * 2 - The shortcode name
		 * 3 - The shortcode argument list
		 * 4 - The self closing /
		 * 5 - The content of a shortcode when it wraps some content.
		 * 6 - An extra ] to allow for escaping shortcodes with double [[]]
		 */
		preg_match_all( '/' . get_shortcode_regex() . '/', $content, $matches, PREG_SET_ORDER );

		foreach ( $matches as $shortcode ) {
			$shortcode_name = $shortcode[2];

			$shortcode_atts    = shortcode_parse_atts( $shortcode[3] );
			$shortcode_content = $shortcode[5];

			/** This is a registered GravityView shortcode. */
			if ( ! empty( self::$shortcodes[ $shortcode_name ] ) ) {
				$shortcode = clone self::$shortcodes[ $shortcode_name ];
			} else {
				/** This is some generic shortcode. */
				$shortcode       = new self();
				$shortcode->name = $shortcode_name;
			}

			$shortcode->atts    = $shortcode_atts;
			$shortcode->content = $shortcode_content;

			/** Merge inner shortcodes. */
			$shortcodes = array_merge( $shortcodes, array( $shortcode ), self::parse( $shortcode_content ) );
		}

		return $shortcodes;
	}

	/**
	 * Returns the View by the provided attributes.
	 *
	 * It will also handle security through the `secret` attribute.
	 *
	 * @since 2.21
	 *
	 * @param array $atts The attributes for the short code.
	 *
	 * @return \GV\View|WP_Error|null The view.
	 */
	protected function get_view_by_atts( array $atts ) {
		if ( ! isset( $atts['view_id'] ) ) {
			return null;
		}

		$view = \GV\View::by_id( $atts['view_id'] );
		if ( ! $view ) {
			return null;
		}

		$secret = rgar( $atts, 'secret', '' );

		if ( $view->validate_secret( $secret ) ) {
			return $view;
		}

		$current_rendering_view_id = \GV\View::get_current_rendering();

		// If the shortcode is included in the View itself, allow it to render without the valid secret.
		if ( $current_rendering_view_id && $current_rendering_view_id == $atts['view_id'] ) {
			return $view;
		}

		$shortcode     = gv_current_shortcode_tag();

		// Shortcode descriptor (safe fallback if missing). Double brackets to avoid the shortcode being parsed in the message.
		$shortcode_message = $shortcode
			? sprintf( '<strong>[[%s]]</strong> %s', esc_html( $shortcode ), esc_html__( 'shortcode', 'gk-gravityview' ) )
			: esc_html__( 'A GravityView shortcode', 'gk-gravityview' );

		if ( GVCommon::has_cap( 'edit_gravityviews', $view->ID ) ) {
			$message_template = esc_html__(
				'The [shortcode] is missing or has an invalid "secret" attribute. Update the shortcode with the following attribute: [secret]',
				'gk-gravityview'
			);

			$message = strtr( $message_template, [
				'[shortcode]' => $shortcode_message,
				'[secret]'    => '<code>secret="' . esc_attr( $view->get_validation_secret() ) . '"</code>',
			] );

			return new WP_Error(
				'invalid_secret',
				$message
			);
		}

		// If the user can't edit the View, don't show the error message with the secret but display an admin notice.
		if ( class_exists( 'GravityKitFoundation' ) && GravityKitFoundation::notices() ) {
			$current_url = home_url( add_query_arg( null, null ) );

			// Key the notice to the host page, not the request URL. A single embed renders on the
			// directory, every single entry, and every edit URL of the same page; keying by the
			// queried post collapses those to one notice instead of one per entry/edit URL. Fall
			// back to the request path when there is no queried post (e.g. a non-singular context).
			$queried_id = get_queried_object_id();
			$page_hash  = $queried_id ? 'post-' . $queried_id : md5( strtok( $current_url, '?' ) );

			// Restore the shortcode brackets for display.
			$shortcode_message = str_replace('[[', '[', $shortcode_message );
			$shortcode_message = str_replace(']]', ']', $shortcode_message );

			$shortcode_key = strtolower( preg_replace( '/[^a-z0-9_]+/i', '-', $shortcode ?: 'gravityview' ) );

			$title = get_the_title();

			if ( ! $title ) {
				$title = __( 'this page', 'gk-gravityview' );
			}

			$page_link = sprintf(
				'<a href="%s" target="_blank">%s</a>',
				esc_url( $current_url ),
				esc_html( $title )
			);

			$message_template = esc_html__(
				'[shortcode] on [page_link] is missing or has an invalid "secret" attribute.',
				'gk-gravityview'
			);

			$message = strtr( $message_template, [
				'[shortcode]' => $shortcode_message,
				'[page_link]' => $page_link,
			] );

			GravityKitFoundation::notices()->add_stored( [
				'message'              => $message,
				'severity'             => 'warning',
				'namespace'            => 'gk-gravityview',
				'globally_dismissible' => true,
				'capabilities'         => [ 'manage_options' ],
				'context'              => 'all',
				'screens'              => [
					'dashboard',
				],
				'slug'                 => sprintf( // Unique per shortcode tag + View + page.
					'gv_invalid_secret_%s_view_%d_page_%s',
					$shortcode_key,
					(int) $view->ID,
					$page_hash
				),
			] );
		}

		$message_template = esc_html__(
			'The [shortcode] is missing or has an invalid "secret" attribute.',
			'gk-gravityview'
		);

		$message = strtr( $message_template, [
			'[shortcode]' => $shortcode_message,
			'[secret]'    => '<code>secret="' . esc_attr( $view->get_validation_secret() ) . '"</code>',
		] );

		return new WP_Error(
			'invalid_secret',
			$message
		);
	}

	/**
	 * Handles a WP_Error.
	 * @param WP_Error $error The error.
	 * @return string The result to return in case of an error.
	 */
	protected function handle_error( WP_Error $error ): string
	{
		// If the user can't edit forms, don't show the error message at all.
		if ( ! GFCommon::current_user_can_any( [ 'gravityforms_edit_forms' ] ) ) {
			return '';
		}

		return '<div><p>' . $error->get_error_message() . '</p></div>';
	}
}
