<?php
/**
 * Rule engine: the core pipeline that decides whether a rule runs and applies
 * it to the output buffer.
 *
 * Architecture:
 *  - Free checks live inside this class (enabled flag, include filters, skip-tags).
 *  - Pro checks (schedule, roles, device, exclusions, regex, selector) hook into
 *    filters exposed here. That lets Freemius strip Pro code without breaking
 *    the engine.
 *
 * Pro filter contract:
 *  - apply_filters( 'esrn_should_apply_rule', bool $apply, array $rule, array $context )
 *      -> return false to block the rule for this request.
 *  - apply_filters( 'esrn_apply_replacement', string $buffer, array $rule )
 *      -> return the replaced buffer. Pro may swap str_replace for preg_replace.
 *
 * @package EasySearchReplace
 */

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

/**
 * Singleton-ish rule engine. Instantiated once from the main plugin file.
 */
class ESRN_Rule_Engine {

	/**
	 * Request-scoped context built once per page render.
	 *
	 * @var array
	 */
	private $context = array();

	/**
	 * Register hooks.
	 */
	public function register() {
		add_action( 'template_redirect', array( $this, 'maybe_start_buffer' ), 101 );
	}

	/**
	 * Open the output buffer only if at least one rule is configured AND we
	 * are on a context that actually renders HTML to an end user. Skipping
	 * feeds / REST / AJAX / admin saves the buffer cost on API traffic.
	 */
	public function maybe_start_buffer() {
		if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
			return;
		}
		if ( function_exists( 'is_feed' ) && is_feed() ) {
			return;
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return;
		}

		$options = get_option( ESRN_OPTION_KEY );
		if ( empty( $options['fields'] ) ) {
			return;
		}
		ob_start( array( $this, 'filter_buffer' ) );
	}

	/**
	 * Main buffer filter. Called once per request by PHP when ob_end_flush runs.
	 *
	 * @param string $buffer Rendered HTML.
	 * @return string
	 */
	public function filter_buffer( $buffer ) {
		global $post;

		// Early exit for non-HTML responses (e.g. JSON or XML that leaked past
		// template_redirect in some edge-case setups). Cheap heuristic.
		$sample = ltrim( substr( $buffer, 0, 64 ) );
		if ( '' === $sample || '{' === $sample[0] || '[' === $sample[0] ) {
			return $buffer;
		}

		$options = get_option( ESRN_OPTION_KEY );
		$rules   = ( isset( $options['fields'] ) && is_array( $options['fields'] ) ) ? $options['fields'] : array();
		if ( empty( $rules ) ) {
			return $buffer;
		}

		// Global setting: which tags to skip entirely. Free for all users.
		// Empty by default - users opt into protection via the Settings tab.
		$skip_tags = $this->resolve_skip_tags( $options );

		// Build per-request context once.
		$this->context = $this->build_context( $post );

		// Normalize every rule so downstream code can rely on field presence.
		foreach ( $rules as $i => $rule ) {
			$rules[ $i ] = ESRN_Rule_Schema::normalize( $rule );
		}

		// Partition into plain vs selector-scoped (selector needs DOM pass + Pro).
		// Regex rules are also tracked separately because they should NOT be run
		// through tag-protection (the user's regex may intentionally match script
		// or style content - stripping those tags first would break the pattern).
		$regex_rules    = array();
		$plain_rules    = array();
		$selector_rules = array();
		foreach ( $rules as $rule ) {
			if ( ! $this->should_apply( $rule ) ) {
				continue;
			}
			$has_selector = ( '' !== (string) $rule['selector'] );
			$is_regex     = ( ! empty( $rule['is_regex'] )       && ESRN_Freemius_Gate::is_pro() );
			$is_between   = ( ! empty( $rule['between_active'] ) && ESRN_Freemius_Gate::is_pro() );

			if ( $has_selector && ESRN_Freemius_Gate::is_css_selector_allowed() ) {
				$selector_rules[] = $rule;
			} elseif ( $is_regex || $is_between ) {
				// Regex and "between" rules run against the raw buffer so
				// patterns matching tags (e.g. "<script>.*</script>") still
				// work as the user intended. Tag protection would strip
				// scripts/styles before the pattern could match them.
				$rule['selector'] = '';
				$regex_rules[]    = $rule;
			} else {
				// Ignore selector in free builds - strip it so apply_rule does a plain replacement.
				$rule['selector'] = '';
				$plain_rules[]    = $rule;
			}
		}

		// 1) Regex rules first, on the untouched buffer.
		foreach ( $regex_rules as $rule ) {
			$buffer = $this->apply_rule( $rule, $buffer );
		}

		// 2) Plain rules run inside tag-protection so skip_tags is honored.
		list( $buffer, $protected ) = $this->protect_tags( $buffer, $skip_tags );

		foreach ( $plain_rules as $rule ) {
			$buffer = $this->apply_rule( $rule, $buffer );
		}

		$buffer = $this->restore_tags( $buffer, $protected );

		if ( ! empty( $selector_rules ) ) {
			$buffer = $this->apply_selector_rules( $selector_rules, $buffer );
		}

		return $buffer;
	}

	/* ---------------------------------------------------------------------
	 * Context + rule evaluation
	 * ------------------------------------------------------------------ */

	/**
	 * Build a per-request snapshot used by every rule evaluator.
	 *
	 * @param WP_Post|null $post Current post object when available.
	 * @return array
	 */
	private function build_context( $post ) {
		$ctx = array(
			'post_id'   => null,
			'post_type' => null,
			'url'       => $this->current_url(),
			'user'      => wp_get_current_user(),
			'timestamp' => current_time( 'timestamp' ), // WP site-timezone timestamp.
		);

		if ( is_singular() && isset( $post->ID ) ) {
			$ctx['post_id']   = (int) $post->ID;
			$ctx['post_type'] = (string) $post->post_type;
		} elseif ( is_home() && get_option( 'page_for_posts' ) ) {
			$ctx['post_id']   = (int) get_option( 'page_for_posts' );
			$ctx['post_type'] = 'page';
		}

		return $ctx;
	}

	/**
	 * Decide whether a rule runs for the current request.
	 *
	 * Free checks short-circuit first (cheapest). Pro checks run through a
	 * filter so premium code can veto.
	 *
	 * @param array $rule Normalized rule.
	 * @return bool
	 */
	private function should_apply( $rule ) {
		// Disabled rules never run.
		if ( empty( $rule['enabled'] ) ) {
			return false;
		}

		// Nothing to search for.
		if ( '' === (string) $rule['search'] ) {
			return false;
		}

		// Include filters (free).
		if ( ! $this->matches_include_filters( $rule ) ) {
			return false;
		}

		/**
		 * Pro extension point.
		 *
		 * Premium classes hook here to add schedule / role / device / exclusion
		 * checks. Return false to block the rule.
		 *
		 * @param bool  $apply   Current decision.
		 * @param array $rule    Normalized rule.
		 * @param array $context Request context.
		 */
		return (bool) apply_filters( 'esrn_should_apply_rule', true, $rule, $this->context );
	}

	/**
	 * The existing free include-filter logic (post type / post ID / URL).
	 *
	 * @param array $rule Normalized rule.
	 * @return bool
	 */
	private function matches_include_filters( $rule ) {
		$types = (array) $rule['post_types'];
		$ids   = (string) $rule['post_ids'];
		$urls  = (string) $rule['urls'];

		// Post-type gate: if set, we must be on a matching singular view.
		if ( ! empty( $types ) ) {
			if ( null === $this->context['post_type'] || ! in_array( $this->context['post_type'], $types, true ) ) {
				return false;
			}
		}

		$has_id_filter  = ( '' !== $ids );
		$has_url_filter = ( '' !== $urls );

		if ( ! $has_id_filter && ! $has_url_filter ) {
			return true;
		}

		if ( $has_id_filter && null !== $this->context['post_id'] ) {
			$allowed = array_filter( array_map( 'intval', array_map( 'trim', explode( ',', $ids ) ) ) );
			if ( in_array( $this->context['post_id'], $allowed, true ) ) {
				return true;
			}
		}

		if ( $has_url_filter ) {
			$allowed = array_filter( array_map( 'trim', explode( "\n", $urls ) ) );
			foreach ( $allowed as $url ) {
				if ( '' !== $url && false !== stripos( $this->context['url'], $url ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/* ---------------------------------------------------------------------
	 * Replacement application
	 * ------------------------------------------------------------------ */

	/**
	 * Apply one rule to the buffer. Handles regex vs plain, case sensitivity.
	 *
	 * @param array  $rule   Normalized rule.
	 * @param string $buffer Current buffer.
	 * @return string
	 */
	private function apply_rule( $rule, $buffer ) {
		// "Match between" mode (Pro). Behaves like a safe regex that won't
		// cross HTML element boundaries, but the user never writes regex.
		if ( ! empty( $rule['between_active'] ) && ESRN_Freemius_Gate::is_pro() ) {
			return $this->between_replace( $rule, $buffer );
		}

		// Regex is Pro-only.
		if ( ! empty( $rule['is_regex'] ) && ESRN_Freemius_Gate::is_pro() ) {
			return $this->regex_replace( $rule, $buffer );
		}

		$search  = (string) $rule['search'];
		$replace = (string) $rule['replace'];

		if ( ! empty( $rule['ignore_case'] ) ) {
			return str_ireplace( $search, $replace, $buffer );
		}
		return str_replace( $search, $replace, $buffer );
	}

	/**
	 * Apply a "match between two markers" rule. We compile a safe regex
	 * behind the scenes and reuse the lazy + tag-bounded character class
	 * to avoid the most common over-match footgun (matching across many
	 * unrelated HTML elements).
	 *
	 * The "between" content is matched with `[\s\S]*?` which:
	 *   - matches any character including newlines
	 *   - is non-greedy so it stops at the first end marker
	 *
	 * Both markers are escaped with preg_quote() so user input is treated
	 * as literal text, not regex syntax.
	 *
	 * @param array  $rule   Normalized rule.
	 * @param string $buffer Buffer to operate on.
	 * @return string
	 */
	private function between_replace( $rule, $buffer ) {
		$start = (string) $rule['between_start'];
		$end   = (string) $rule['between_end'];

		if ( '' === $start || '' === $end ) {
			return $buffer;
		}

		$delim   = '#';
		$pattern = $delim
			. preg_quote( $start, $delim )
			. '[\s\S]*?'
			. preg_quote( $end, $delim )
			. $delim
			. ( ! empty( $rule['ignore_case'] ) ? 'iu' : 'u' );

		// Build a literal replacement - escape any $n backreferences the user
		// might accidentally have typed so they're treated as text.
		$replacement = (string) $rule['replace'];
		$replacement = str_replace( array( '\\', '$' ), array( '\\\\', '\$' ), $replacement );

		$result = @preg_replace( $pattern, $replacement, $buffer );
		return ( null === $result ) ? $buffer : $result;
	}

	/**
	 * Smart regex apply. Accepts either a pre-delimited pattern ("/foo/i") or a
	 * raw pattern that we wrap automatically. Invalid patterns return the
	 * original buffer untouched so a bad rule can't nuke the page.
	 *
	 * @param array  $rule   Normalized rule.
	 * @param string $buffer Buffer to operate on.
	 * @return string
	 */
	private function regex_replace( $rule, $buffer ) {
		$pattern = $this->build_regex_pattern( (string) $rule['search'], ! empty( $rule['ignore_case'] ) );
		if ( '' === $pattern ) {
			return $buffer;
		}

		// @ suppresses warnings - an invalid pattern just means preg_replace returns null.
		$result = @preg_replace( $pattern, (string) $rule['replace'], $buffer );
		return ( null === $result ) ? $buffer : $result;
	}

	/**
	 * Wrap a user-entered pattern with safe delimiters + flags.
	 *
	 * Flags we always add:
	 *   u - UTF-8 mode (safe default for non-ASCII content)
	 *   s - "dotall" mode, so `.` matches newlines. This is almost always
	 *       what users want when matching HTML that spans multiple lines,
	 *       and avoids a very common "my pattern doesn't match" gotcha.
	 *   i - added only when the rule's "case-insensitive" flag is on.
	 *
	 * Users who need fine-grained control can still write a fully-delimited
	 * pattern (e.g. `/foo/` or `~bar~i`) and we'll use it as-is.
	 *
	 * @param string $raw         Pattern as entered by the user.
	 * @param bool   $ignore_case Apply /i flag.
	 * @return string Final pattern, or empty string on failure.
	 */
	private function build_regex_pattern( $raw, $ignore_case ) {
		if ( '' === $raw ) {
			return '';
		}

		// If already delimited (first char matches closing delimiter somewhere
		// plus optional flags), trust the user. Otherwise we add our own.
		if ( preg_match( '/^(.).+\1[a-zA-Z]*$/s', $raw ) ) {
			return $raw;
		}

		$flags     = $ignore_case ? 'ius' : 'us';
		$delimiter = '#';
		// Escape any occurrence of our delimiter in the raw pattern.
		$escaped = str_replace( $delimiter, '\\' . $delimiter, $raw );

		return $delimiter . $escaped . $delimiter . $flags;
	}

	/**
	 * Second pass: selector-scoped rules require DOM parsing.
	 *
	 * Only reached in Pro builds. The parser is loaded from lib/dom.php.
	 *
	 * @param array  $rules  Array of rules that have a non-empty selector.
	 * @param string $buffer Current buffer.
	 * @return string
	 */
	private function apply_selector_rules( $rules, $buffer ) {
		if ( ! function_exists( 'esr_str_get_html' ) ) {
			return $buffer;
		}

		$dom = esr_str_get_html( $buffer, false, true, 'UTF-8', false, PHP_EOL, ' ' );
		if ( ! $dom ) {
			return $buffer;
		}

		foreach ( $rules as $rule ) {
			$elements = $dom->find( (string) $rule['selector'] );
			if ( empty( $elements ) ) {
				continue;
			}

			foreach ( $elements as $element ) {
				$element->innertext = $this->replace_in_string( $rule, $element->innertext );
			}
		}

		return $dom->save();
	}

	/**
	 * Apply one rule's replacement to a string (used for selector pass).
	 *
	 * @param array  $rule Normalized rule.
	 * @param string $str  String to transform.
	 * @return string
	 */
	private function replace_in_string( $rule, $str ) {
		if ( ! empty( $rule['is_regex'] ) ) {
			$pattern = $this->build_regex_pattern( (string) $rule['search'], ! empty( $rule['ignore_case'] ) );
			if ( '' === $pattern ) {
				return $str;
			}
			$result = @preg_replace( $pattern, (string) $rule['replace'], $str );
			return ( null === $result ) ? $str : $result;
		}

		if ( ! empty( $rule['ignore_case'] ) ) {
			return str_ireplace( $rule['search'], $rule['replace'], $str );
		}
		return str_replace( $rule['search'], $rule['replace'], $str );
	}

	/* ---------------------------------------------------------------------
	 * Tag protection (skip script/style)
	 * ------------------------------------------------------------------ */

	/**
	 * Determine which HTML tags to protect from replacement.
	 *
	 * Default: NO protection - users can replace any text, including inside
	 * <script> and <style>. The "Skip HTML tags" setting (free for all users)
	 * lets them opt into protecting specific tags.
	 *
	 * @param array $options Full option array.
	 * @return array Tag names (lowercase, no angle brackets).
	 */
	private function resolve_skip_tags( $options ) {
		$configured = isset( $options['skip_tags'] ) && is_array( $options['skip_tags'] )
			? $options['skip_tags']
			: array();

		// Normalize - lowercase, alnum only.
		$clean = array();
		foreach ( $configured as $tag ) {
			$tag = strtolower( preg_replace( '/[^a-z0-9]/i', '', (string) $tag ) );
			if ( '' !== $tag ) {
				$clean[] = $tag;
			}
		}
		return array_values( array_unique( $clean ) );
	}

	/**
	 * Temporarily swap out protected tags so replacements don't touch their
	 * contents. Returns modified buffer + a map of placeholder -> original.
	 *
	 * @param string $buffer HTML.
	 * @param array  $tags   Tags to protect.
	 * @return array [ string $new_buffer, array $placeholders ]
	 */
	private function protect_tags( $buffer, $tags ) {
		$placeholders = array();

		if ( empty( $tags ) ) {
			return array( $buffer, $placeholders );
		}

		$token_prefix = "\x01ESRN_PROTECT_";
		$counter      = 0;

		foreach ( $tags as $tag ) {
			$tag_escaped = preg_quote( $tag, '#' );
			$pattern     = '#<' . $tag_escaped . '\b[^>]*>.*?</' . $tag_escaped . '\s*>#is';

			$buffer = preg_replace_callback(
				$pattern,
				function ( $m ) use ( &$placeholders, &$counter, $token_prefix ) {
					$token                  = $token_prefix . $counter++ . "\x01";
					$placeholders[ $token ] = $m[0];
					return $token;
				},
				$buffer
			);
		}

		return array( $buffer, $placeholders );
	}

	/**
	 * Swap the protected tags back into the buffer.
	 *
	 * @param string $buffer       Buffer with placeholders.
	 * @param array  $placeholders Token => original HTML.
	 * @return string
	 */
	private function restore_tags( $buffer, $placeholders ) {
		if ( empty( $placeholders ) ) {
			return $buffer;
		}
		return strtr( $buffer, $placeholders );
	}

	/* ---------------------------------------------------------------------
	 * Utilities
	 * ------------------------------------------------------------------ */

	/**
	 * Safe absolute URL of the current request.
	 *
	 * @return string
	 */
	private function current_url() {
		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
			return home_url( '/' );
		}
		$uri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) );
		return esc_url_raw( home_url( $uri ) );
	}
}
