<?php
/**
 * ContentFilter
 *
 * Hooks into the_content and inserts inline + end-content related posts.
 * Carries forward the battle-tested paragraph-splitting and exclusion-stack
 * logic from v1.9.x, now as a clean namespaced class.
 *
 * @package Relatedly\Frontend
 * @since   2.0.0
 */

namespace Relatedly\Frontend;

use Relatedly\Core\Options;
use Relatedly\Core\QueryBuilder;
use Relatedly\Premium\FeatureGate;
use Relatedly\Theming\ThemeRegistry;

defined( 'ABSPATH' ) || exit;

/**
 * Inserts related-post blocks into post content.
 *
 * @since 2.0.0
 */
class ContentFilter {

	/**
	 * Plugin settings repository.
	 *
	 * @since 2.0.0
	 * @var Options
	 */
	private Options $options;

	/**
	 * Related posts query builder.
	 *
	 * @since 2.0.0
	 * @var QueryBuilder
	 */
	private QueryBuilder $query;

	/**
	 * Theme renderer registry.
	 *
	 * @since 2.0.0
	 * @var ThemeRegistry
	 */
	private ThemeRegistry $themes;

	/**
	 * Prevents re-entrant calls when the_content fires inside a theme render.
	 *
	 * @since 2.0.0
	 * @var bool
	 */
	private static bool $processing = false;

	/**
	 * Create the content filter service.
	 *
	 * @since 2.0.0
	 * @param Options       $options Plugin settings repository.
	 * @param QueryBuilder  $query   Related posts query builder.
	 * @param ThemeRegistry $themes  Theme renderer registry.
	 */
	public function __construct( Options $options, QueryBuilder $query, ThemeRegistry $themes ) {
		$this->options = $options;
		$this->query   = $query;
		$this->themes  = $themes;
	}

	/**
	 * Register content filtering hooks.
	 *
	 * @since 2.0.0
	 */
	public function init_hooks(): void {
		add_filter( 'the_content', [ $this, 'filter_content' ], 20 );
	}

	// -------------------------------------------------------------------------
	// Main filter
	// -------------------------------------------------------------------------

	/**
	 * Entry point for the_content hook.
	 *
	 * @since 2.0.0
	 * @param string $content Original post content.
	 * @return string Filtered post content.
	 */
	public function filter_content( string $content ): string {
		if ( self::$processing ) {
			return $content;
		}

		if ( ! is_single() || ! $this->options->get( 'enabled', true ) ) {
			return $content;
		}

		$post = get_post();
		if ( ! $post ) {
			return $content;
		}

		$enabled_types = (array) $this->options->get( 'post_types', [ 'post' ] );
		if ( ! in_array( get_post_type( $post ), $enabled_types, true ) ) {
			return $content;
		}

		self::$processing = true;

		if ( $this->options->get( 'inline_enabled', true ) ) {
			$content = $this->insert_inline( $content, $post->ID );
		}

		if ( $this->options->get( 'end_content_enabled', true ) ) {
			$content = $this->append_end_content( $content, $post->ID );
		}

		self::$processing = false;

		return $content;
	}

	// -------------------------------------------------------------------------
	// Inline insertion
	// -------------------------------------------------------------------------

	/**
	 * Insert inline related posts after configured paragraph intervals.
	 *
	 * @param string $content Original post content.
	 * @param int    $post_id Source post ID.
	 * @return string Filtered post content.
	 */
	private function insert_inline( string $content, int $post_id ): string {
		$interval     = (int) $this->options->get( 'inline_paragraph_interval', 4 );
		$theme_key    = (string) $this->options->get( 'inline_theme', 'theme1' );
		$end_enabled  = (bool) $this->options->get( 'end_content_enabled', true );
		$min_trailing = 3;
		$max_inline   = FeatureGate::max_inline_posts();

		$pool = $this->query->get_related_posts( $post_id, 10, 'inline' );
		if ( empty( $pool ) ) {
			return $content;
		}

		$blocks = $this->split_content( $content );
		if ( count( $blocks ) < $interval ) {
			return $content;
		}

		// Build block states with exclusion awareness.
		$states = [];
		$stack  = [];

		foreach ( $blocks as $block ) {
			$was_excluded = ! empty( $stack );
			$this->update_exclusion_stack( $block, $stack );
			$excluded = $was_excluded || ! empty( $stack ) || $this->is_block_excluded( $block );

			$states[] = [
				'content'       => $block,
				'excluded'      => $excluded,
				'has_paragraph' => (bool) preg_match( '/<p[^>]*>.*?<\/p>/is', $block ),
			];
		}

		// Count valid paragraphs for the trailing-space guard.
		$total_valid = 0;
		if ( $end_enabled ) {
			foreach ( $states as $s ) {
				if ( ! $s['excluded'] && $s['has_paragraph'] ) {
					++$total_valid;
				}
			}
		}

		$result     = [];
		$para_count = 0;
		$pool_index = 0;
		$pool_size  = count( $pool );

		foreach ( $states as $state ) {
			$result[] = $state['content'];

			if ( $state['excluded'] || ! $state['has_paragraph'] ) {
				continue;
			}

			++$para_count;

			if ( 0 !== $para_count % $interval ) {
				continue;
			}

			// Don't insert if too close to where end-content will appear.
			if ( $end_enabled && ( $total_valid - $para_count ) < $min_trailing ) {
				continue;
			}

			if ( $max_inline <= $pool_index ) {
				continue;
			}

			$single   = [ $pool[ $pool_index % $pool_size ] ];
			$result[] = $this->themes->render( 'inline', $theme_key, $single, $post_id );
			++$pool_index;
		}

		return implode( '', $result );
	}

	// -------------------------------------------------------------------------
	// End-content append
	// -------------------------------------------------------------------------

	/**
	 * Append end-content related posts after the main post body.
	 *
	 * @param string $content Original post content.
	 * @param int    $post_id Source post ID.
	 * @return string Filtered post content.
	 */
	private function append_end_content( string $content, int $post_id ): string {
		$max_posts = min(
			(int) $this->options->get( 'end_content_max_posts', 5 ),
			FeatureGate::max_end_content_posts()
		);
		$theme_key = (string) $this->options->get( 'end_content_theme', 'theme1' );

		$posts = $this->query->get_related_posts( $post_id, $max_posts, 'end_content' );
		if ( empty( $posts ) ) {
			return $content;
		}

		return $content . $this->themes->render( 'end_content', $theme_key, $posts, $post_id );
	}

	// -------------------------------------------------------------------------
	// Paragraph splitting (carried forward from v1.9.x)
	// -------------------------------------------------------------------------

	/**
	 * Split content into blocks, preserving all HTML structure.
	 *
	 * @param string $content Original post content.
	 * @return string[]
	 * @since 2.0.0
	 */
	private function split_content( string $content ): array {
		$parts  = preg_split( '/(<\/p>)/i', $content, -1, PREG_SPLIT_DELIM_CAPTURE );
		$result = [];
		$buffer = '';

		foreach ( $parts as $part ) {
			if ( '</p>' === strtolower( $part ) ) {
				$buffer .= $part;
				if ( preg_match( '/<p[^>]*>/i', $buffer ) ) {
					if ( '' !== trim( wp_strip_all_tags( $buffer ) ) ) {
						$result[] = $buffer;
					}
				} else {
					$result[] = $buffer;
				}
				$buffer = '';
			} else {
				$buffer .= $part;
			}
		}

		if ( '' !== $buffer ) {
			$result[] = $buffer;
		}

		return $result;
	}

	// -------------------------------------------------------------------------
	// Exclusion engine (carried forward from v1.9.x)
	// -------------------------------------------------------------------------

	/**
	 * Determine whether a content block should never receive an insertion.
	 *
	 * @param string $block HTML content block.
	 * @return bool
	 */
	private function is_block_excluded( string $block ): bool {
		$markers = apply_filters( 'relatedly_excluded_elements', $this->excluded_markers() );
		foreach ( $markers as $marker ) {
			if ( false !== stripos( $block, $marker ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Track nested excluded elements across split content blocks.
	 *
	 * @param string            $block HTML content block.
	 * @param array<int,string> $stack Open excluded tags.
	 */
	private function update_exclusion_stack( string $block, array &$stack ): void {
		if ( ! preg_match_all( '/<(\/)?([a-z0-9]+)([^>]*)>/i', $block, $matches, PREG_SET_ORDER ) ) {
			return;
		}
		$rules = $this->exclusion_rules();

		foreach ( $matches as $m ) {
			$closing    = '/' === $m[1];
			$tag        = strtolower( $m[2] );
			$attributes = $m[3];

			if ( $closing ) {
				$this->pop_stack( $tag, $stack );
				continue;
			}

			if ( isset( $rules[ $tag ] ) && $this->tag_matches_rule( $rules[ $tag ], $attributes ) ) {
				$stack[] = $tag;
				if ( count( $stack ) > 20 ) {
					array_shift( $stack );
				}
			}
		}
	}

	/**
	 * Determine whether an HTML tag's attributes match an exclusion rule.
	 *
	 * @param array<string,mixed> $rule       Exclusion rule.
	 * @param string              $attributes Raw HTML attributes.
	 * @return bool
	 */
	private function tag_matches_rule( array $rule, string $attributes ): bool {
		if ( ! empty( $rule['match_any'] ) ) {
			return true;
		}
		if ( empty( $rule['classes'] ) ) {
			return false;
		}
		$classes = [];
		if ( preg_match( '/class\s*=\s*(["\'])([^"\']+)\1/i', $attributes, $cm ) ) {
			$classes = array_filter( preg_split( '/\s+/', strtolower( $cm[2] ) ) );
		}
		foreach ( $rule['classes'] as $cls ) {
			if ( in_array( $cls, $classes, true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Remove the last matching tag from the exclusion stack.
	 *
	 * @param string            $tag   Closing tag name.
	 * @param array<int,string> $stack Open excluded tags.
	 */
	private function pop_stack( string $tag, array &$stack ): void {
		for ( $i = count( $stack ) - 1; $i >= 0; $i-- ) {
			if ( $stack[ $i ] === $tag ) {
				array_splice( $stack, $i, 1 );
				return;
			}
		}
	}

	/**
	 * Get HTML tag and class rules that mark blocks as excluded.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private function exclusion_rules(): array {
		return [
			'blockquote' => [ 'match_any' => true ],
			'table'      => [ 'match_any' => true ],
			'figure'     => [ 'match_any' => true ],
			'pre'        => [ 'match_any' => true ],
			'code'       => [ 'match_any' => true ],
			'aside'      => [ 'match_any' => true ],
			'nav'        => [ 'match_any' => true ],
			'form'       => [ 'match_any' => true ],
			'fieldset'   => [ 'match_any' => true ],
			'div'        => [
				'classes' => [
					'toc-container',
					'table-of-contents',
					'lwptoc',
					'ez-toc',
					'elementor-widget',
					'elementor-element',
					'wp-block-yoast-faq-block',
				],
			],
			'section'    => [ 'classes' => [ 'elementor-section' ] ],
		];
	}

	/**
	 * Get literal markers that mark a block as excluded.
	 *
	 * @return string[]
	 */
	private function excluded_markers(): array {
		return [
			'<blockquote',
			'<table',
			'<figure',
			'<pre',
			'<code',
			'<aside',
			'<nav',
			'<form',
			'<fieldset',
			'wp-block-quote',
			'wp-block-pullquote',
			'wp-block-table',
			'wp-block-verse',
			'wp-block-preformatted',
			'wp-block-code',
			'wp-block-embed',
			'wp-block-gallery',
			'wp-block-image',
			'wp-block-video',
			'wp-block-audio',
			'has-fixed-layout',
			'tablepress',
			'wp-caption',
			'gallery',
			'toc-container',
			'table-of-contents',
			'lwptoc',
			'ez-toc',
			'elementor-widget',
			'elementor-element',
			'wp-block-yoast-faq-block',
		];
	}
}
