<?php

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

/**
 * Combines standard WordPress content with supported page-builder storage.
 *
 * The extractor never renders a public URL and never executes builder code. It
 * reads stored fields through WordPress APIs, keeps visitor-facing text, and
 * exposes a filter for site-specific adapters.
 */
class Content_Miner_Content_Extractor {
	const MAX_DEPTH     = 12;
	const MAX_FRAGMENTS = 1000;
	const MAX_BYTES     = 2097152;

	/** @var int */
	private $fragment_bytes = 0;

	/**
	 * @param WP_Post $post WordPress post object.
	 * @return array<string,mixed>
	 */
	public function extract( $post ) {
		$this->fragment_bytes = 0;
		$fragments = array();
		$sources   = array();
		$warnings  = array();
		$raw       = isset( $post->post_content ) ? (string) $post->post_content : '';

		$editor_html = $this->render_editor_content( $raw );
		if ( '' !== $this->plain_text( $editor_html ) ) {
			$this->add_fragment( $fragments, $editor_html, 'content' );
			$sources[] = $this->editor_source( $raw );
		}

		$this->add_betheme_content( $post->ID, $fragments, $sources, $warnings );

		$this->add_structured_meta(
			$post->ID,
			'_elementor_data',
			'Elementor',
			$fragments,
			$sources,
			$warnings
		);

		$html = implode( "\n", array_values( $fragments ) );
		$data = array(
			'html'     => $html,
			'text'     => $this->plain_text( $html ),
			'sources'  => array_values( array_unique( array_filter( $sources ) ) ),
			'warnings' => array_values( array_unique( $warnings ) ),
		);

		/**
		 * Extend or replace extracted content for a site-specific storage system.
		 *
		 * @param array<string,mixed> $data Extracted html, text, sources, warnings.
		 * @param WP_Post             $post Source post.
		 */
		$data = apply_filters( 'content_miner_extracted_content', $data, $post );

		return $this->normalize_result( $data );
	}

	/**
	 * Render stored editor content without executing unresolved shortcodes.
	 *
	 * @param string $raw Raw post content.
	 * @return string
	 */
	private function render_editor_content( $raw ) {
		$html = function_exists( 'do_blocks' ) ? do_blocks( $raw ) : $raw;
		$html .= "\n" . $this->extract_shortcode_attributes( $raw );
		$html = strip_shortcodes( $html );

		return $this->sanitize_fragment( $html, 'content' );
	}

	/**
	 * Identify builders that store visitor copy in post_content shortcodes.
	 *
	 * @param string $raw Raw content.
	 * @return string
	 */
	private function editor_source( $raw ) {
		if ( false !== strpos( $raw, '[et_pb_' ) ) {
			return 'Divi Builder';
		}
		if ( false !== strpos( $raw, '[vc_' ) || false !== strpos( $raw, '[wpb_' ) ) {
			return 'WPBakery';
		}

		return 'WordPress editor';
	}

	/**
	 * Read a structured builder field and append visitor-facing fragments.
	 *
	 * @param int      $post_id Post ID.
	 * @param string   $meta_key Builder meta key.
	 * @param string   $label Source label.
	 * @param string[] $fragments Fragments by normalized hash.
	 * @param string[] $sources Sources.
	 * @param string[] $warnings Warnings.
	 */
	private function add_structured_meta( $post_id, $meta_key, $label, array &$fragments, array &$sources, array &$warnings ) {
		$raw = get_post_meta( $post_id, $meta_key, true );
		if ( '' === $raw || null === $raw || array() === $raw ) {
			return;
		}

		$data   = $this->decode_structured_value( $raw );
		$before = count( $fragments );
		$this->walk_builder_data( $data, '', $fragments, 0 );

		if ( count( $fragments ) > $before ) {
			$sources[] = $label;
		} else {
			$warnings[] = sprintf( '%s data was detected but no readable content fields were found.', $label );
		}
	}

	/**
	 * Read Betheme's readable or encoded builder field, with its SEO mirror as
	 * a fallback for installations using an unsupported storage variant.
	 *
	 * @param int      $post_id Post ID.
	 * @param string[] $fragments Fragments by normalized hash.
	 * @param string[] $sources Sources.
	 * @param string[] $warnings Warnings.
	 */
	private function add_betheme_content( $post_id, array &$fragments, array &$sources, array &$warnings ) {
		$raw = get_post_meta( $post_id, 'mfn-page-items', true );
		if ( '' === $raw || null === $raw || array() === $raw ) {
			return;
		}

		$before = count( $fragments );
		$this->walk_builder_data( $this->decode_structured_value( $raw ), '', $fragments, 0 );
		if ( count( $fragments ) > $before ) {
			$sources[] = 'Betheme Builder';
			return;
		}

		$seo_mirror = get_post_meta( $post_id, 'mfn-page-items-seo', true );
		$this->add_fragment( $fragments, is_scalar( $seo_mirror ) ? (string) $seo_mirror : '', 'content' );
		if ( count( $fragments ) > $before ) {
			$sources[] = 'Betheme SEO mirror';
			return;
		}

		$warnings[] = 'Betheme Builder data was detected but no readable content fields were found.';
	}

	/**
	 * @param mixed $value Stored builder value.
	 * @return mixed
	 */
	private function decode_structured_value( $value ) {
		if ( ! is_string( $value ) ) {
			return $value;
		}

		// Reject oversized encoded builder payloads before JSON/base64/unserialize
		// expansion. The caller records an extraction warning or uses its safe
		// SEO-mirror fallback instead of risking a batch memory spike.
		if ( strlen( $value ) > ( self::MAX_BYTES * 4 ) ) {
			return '';
		}

		$trimmed = trim( $value );
		$decoded = $this->decode_json( $trimmed );
		if ( null !== $decoded ) {
			return $decoded;
		}

		$unserialized = $this->safe_unserialize( $trimmed );
		if ( null !== $unserialized ) {
			return $unserialized;
		}

		if ( strlen( $trimmed ) <= ( self::MAX_BYTES * 4 ) ) {
			$base64 = base64_decode( $trimmed, true );
			if ( false !== $base64 ) {
				$decoded = $this->decode_json( trim( $base64 ) );
				if ( null !== $decoded ) {
					return $decoded;
				}
				$unserialized = $this->safe_unserialize( trim( $base64 ) );
				if ( null !== $unserialized ) {
					return $unserialized;
				}
			}
		}

		return $value;
	}

	/**
	 * @param string $value Potential JSON.
	 * @return mixed|null
	 */
	private function decode_json( $value ) {
		if ( '' === $value || ! in_array( $value[0], array( '{', '[' ), true ) ) {
			return null;
		}
		$decoded = json_decode( $value, true );
		return JSON_ERROR_NONE === json_last_error() ? $decoded : null;
	}

	/**
	 * Decode arrays and scalars without allowing serialized PHP objects.
	 *
	 * @param string $value Potential serialized value.
	 * @return mixed|null
	 */
	private function safe_unserialize( $value ) {
		if ( ! preg_match( '/^(?:a|s|i|b|d|N):?/', $value ) ) {
			return null;
		}
		$decoded = @unserialize( $value, array( 'allowed_classes' => false ) );
		return false !== $decoded || 'b:0;' === $value ? $decoded : null;
	}

	/**
	 * Recursively collect text-bearing fields from builder arrays.
	 *
	 * @param mixed    $value Builder data.
	 * @param string   $key Current field key.
	 * @param string[] $fragments Fragments by normalized hash.
	 * @param int      $depth Current recursion depth.
	 */
	private function walk_builder_data( $value, $key, array &$fragments, $depth ) {
		if ( $depth > self::MAX_DEPTH || count( $fragments ) >= self::MAX_FRAGMENTS ) {
			return;
		}

		if ( is_array( $value ) ) {
			foreach ( $value as $child_key => $child_value ) {
				$this->walk_builder_data( $child_value, (string) $child_key, $fragments, $depth + 1 );
				if ( count( $fragments ) >= self::MAX_FRAGMENTS ) {
					break;
				}
			}
			return;
		}

		if ( ! is_scalar( $value ) || ! $this->is_text_field( $key ) ) {
			return;
		}

		$this->add_fragment( $fragments, (string) $value, $key );
	}

	/**
	 * @param string $key Builder field key.
	 * @return bool
	 */
	private function is_text_field( $key ) {
		$key = strtolower( trim( $key ) );
		if ( '' === $key || preg_match( '/(?:url|href|src|image|icon|color|font|style|class|\bid\b|css|animation|margin|padding|background|template|layout|width|height|breakpoint|taxonomy)/', $key ) ) {
			return false;
		}

		return (bool) preg_match( '/(?:^|_)(?:content|text|title|heading|description|editor|caption|quote|question|answer|label|intro|subtitle|excerpt|message)(?:_|$)/', $key );
	}

	/**
	 * Preserve user-visible shortcode attributes such as module titles.
	 *
	 * @param string $raw Raw post content.
	 * @return string
	 */
	private function extract_shortcode_attributes( $raw ) {
		$fragments = array();
		$tags      = array();
		preg_match_all( '/\[(?:et_pb|vc|wpb)_[^\]]+\]/i', $raw, $tags );

		foreach ( isset( $tags[0] ) ? $tags[0] : array() as $tag ) {
			$attributes = array();
			preg_match_all( '/\b([a-z0-9_-]+)\s*=\s*(["\'])(.*?)\2/is', $tag, $attributes, PREG_SET_ORDER );
			foreach ( $attributes as $attribute ) {
				if ( $this->is_text_field( $attribute[1] ) ) {
					$this->add_fragment( $fragments, html_entity_decode( $attribute[3], ENT_QUOTES | ENT_HTML5, 'UTF-8' ), $attribute[1] );
				}
			}
		}

		return implode( "\n", array_values( $fragments ) );
	}

	/**
	 * @param string[] $fragments Fragments by normalized hash.
	 * @param string   $value Candidate fragment.
	 * @param string   $key Source field key.
	 */
	private function add_fragment( array &$fragments, $value, $key ) {
		if ( count( $fragments ) >= self::MAX_FRAGMENTS ) {
			return;
		}

		$value = $this->sanitize_fragment( $value, $key );
		$text  = $this->plain_text( $value );
		if ( '' === $text ) {
			return;
		}

		$hash = hash( 'sha256', strtolower( preg_replace( '/\s+/u', ' ', $text ) ) );
		if ( isset( $fragments[ $hash ] ) ) {
			return;
		}

		if ( $this->fragment_bytes + strlen( $value ) > self::MAX_BYTES ) {
			return;
		}

		$fragments[ $hash ] = $value;
		$this->fragment_bytes += strlen( $value );
	}

	/**
	 * @param string $value Candidate HTML or text.
	 * @param string $key Source field key.
	 * @return string
	 */
	private function sanitize_fragment( $value, $key ) {
		$value = preg_replace( '#<(script|style|noscript)\b[^>]*>.*?</\1>#is', ' ', (string) $value );
		$value = trim( $value );
		if ( '' === $value ) {
			return '';
		}

		if ( false !== strpos( $value, '<' ) ) {
			return wp_kses_post( $value );
		}

		$tag = preg_match( '/(?:title|heading|question)/i', $key ) ? 'h2' : 'p';
		return '<' . $tag . '>' . esc_html( $value ) . '</' . $tag . '>';
	}

	/**
	 * @param string $html HTML.
	 * @return string
	 */
	private function plain_text( $html ) {
		$text = html_entity_decode( wp_strip_all_tags( (string) $html, true ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		return trim( preg_replace( '/\s+/u', ' ', $text ) );
	}

	/**
	 * Normalize third-party filter output before returning it to the repository.
	 *
	 * @param mixed $data Filtered result.
	 * @return array<string,mixed>
	 */
	private function normalize_result( $data ) {
		$data     = is_array( $data ) ? $data : array();
		$html     = isset( $data['html'] ) ? $this->sanitize_fragment( $data['html'], 'content' ) : '';
		$sources  = isset( $data['sources'] ) && is_array( $data['sources'] ) ? array_map( 'sanitize_text_field', $data['sources'] ) : array();
		$warnings = isset( $data['warnings'] ) && is_array( $data['warnings'] ) ? array_map( 'sanitize_text_field', $data['warnings'] ) : array();

		return array(
			'html'     => $html,
			'text'     => $this->plain_text( $html ),
			'sources'  => array_values( array_unique( array_filter( $sources ) ) ),
			'warnings' => array_values( array_unique( array_filter( $warnings ) ) ),
		);
	}
}
