<?php
/**
 * The field extractor: rendered HTML in, normalized SEO-critical field set out.
 *
 * Pure and side-effect free by design — the whole diff engine is only as
 * trustworthy as this file, so it must behave identically for a fixture string
 * and a live fetch.
 *
 * @package SEO_Flight_Recorder
 */

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

/**
 * Class SFRec_Parser
 */
class SFRec_Parser {

	/**
	 * Hard cap on the HTML we parse. Beyond this we truncate: the fields we
	 * care about live in <head> plus the first H1, so 2 MB is generous.
	 */
	const MAX_BYTES = 2097152;

	/**
	 * Extract the SEO-critical field set from a rendered page.
	 *
	 * @param string $html    Raw response body.
	 * @param array  $headers Response headers (lowercased keys => string values).
	 * @param int    $status  HTTP status code.
	 * @param string $redirect_to Final/redirect location when the URL redirected ('' when none).
	 * @return array Normalized field set (all keys always present).
	 */
	public static function extract( $html, $headers = array(), $status = 200, $redirect_to = '' ) {
		$fields = array(
			'http_status'      => (int) $status,
			'redirect_to'      => (string) $redirect_to,
			'title'            => '',
			'meta_description' => '',
			'meta_robots'      => '',
			'x_robots_tag'     => '',
			'noindex'          => false,
			'canonical'        => '',
			'schema_types'     => array(),
			'og'               => array(),
			'twitter'          => array(),
			'h1_count'         => 0,
			'h1_first'         => '',
		);

		if ( isset( $headers['x-robots-tag'] ) ) {
			$fields['x_robots_tag'] = self::clean_text( $headers['x-robots-tag'] );
		}

		$html = (string) $html;
		if ( '' === $html ) {
			$fields['noindex'] = self::is_noindex( '', $fields['x_robots_tag'] );
			return $fields;
		}
		if ( strlen( $html ) > self::MAX_BYTES ) {
			$html = substr( $html, 0, self::MAX_BYTES );
		}

		$previous_libxml = libxml_use_internal_errors( true );
		$dom             = new DOMDocument();
		// The XML prolog trick keeps DOMDocument treating the source as UTF-8
		// regardless of the document's own (possibly missing) charset meta.
		$dom->loadHTML( '<?xml encoding="utf-8"?>' . $html );
		libxml_clear_errors();
		libxml_use_internal_errors( $previous_libxml );

		// <title> — the first one in the document.
		$titles = $dom->getElementsByTagName( 'title' );
		if ( $titles->length > 0 ) {
			$fields['title'] = self::clean_text( $titles->item( 0 )->textContent );
		}

		// <meta> and <link rel="canonical">.
		foreach ( $dom->getElementsByTagName( 'meta' ) as $meta ) {
			$name     = strtolower( trim( (string) $meta->getAttribute( 'name' ) ) );
			$property = strtolower( trim( (string) $meta->getAttribute( 'property' ) ) );
			$content  = self::clean_text( (string) $meta->getAttribute( 'content' ) );

			if ( 'description' === $name && '' === $fields['meta_description'] ) {
				$fields['meta_description'] = $content;
			} elseif ( 'robots' === $name && '' === $fields['meta_robots'] ) {
				$fields['meta_robots'] = strtolower( $content );
			} elseif ( 0 === strpos( $property, 'og:' ) ) {
				$key = substr( $property, 3 );
				if ( in_array( $key, array( 'title', 'description', 'image', 'type', 'url' ), true ) && ! isset( $fields['og'][ $key ] ) ) {
					$fields['og'][ $key ] = $content;
				}
			} elseif ( 0 === strpos( $name, 'twitter:' ) ) {
				$key = substr( $name, 8 );
				if ( in_array( $key, array( 'card', 'title', 'description', 'image' ), true ) && ! isset( $fields['twitter'][ $key ] ) ) {
					$fields['twitter'][ $key ] = $content;
				}
			}
		}

		foreach ( $dom->getElementsByTagName( 'link' ) as $link ) {
			if ( 'canonical' === strtolower( trim( (string) $link->getAttribute( 'rel' ) ) ) && '' === $fields['canonical'] ) {
				$fields['canonical'] = self::clean_text( (string) $link->getAttribute( 'href' ) );
			}
		}

		// JSON-LD schema types.
		foreach ( $dom->getElementsByTagName( 'script' ) as $script ) {
			$type = strtolower( trim( (string) $script->getAttribute( 'type' ) ) );
			if ( 'application/ld+json' !== $type ) {
				continue;
			}
			$decoded = json_decode( (string) $script->textContent, true );
			if ( null === $decoded ) {
				continue;
			}
			self::collect_schema_types( $decoded, $fields['schema_types'] );
		}
		// Normalize: unique sorted type => count map for stable comparison.
		ksort( $fields['schema_types'] );

		// H1s.
		$h1s                = $dom->getElementsByTagName( 'h1' );
		$fields['h1_count'] = $h1s->length;
		if ( $h1s->length > 0 ) {
			$fields['h1_first'] = self::clean_text( $h1s->item( 0 )->textContent );
		}

		ksort( $fields['og'] );
		ksort( $fields['twitter'] );

		$fields['noindex'] = self::is_noindex( $fields['meta_robots'], $fields['x_robots_tag'] );

		return $fields;
	}

	/**
	 * Walk decoded JSON-LD (handles @graph and nested arrays) collecting @type
	 * values into a type => count map.
	 *
	 * @param mixed $node  Decoded JSON node.
	 * @param array $types Accumulator (by reference).
	 * @param int   $depth Recursion guard.
	 */
	private static function collect_schema_types( $node, &$types, $depth = 0 ) {
		if ( $depth > 8 || ! is_array( $node ) ) {
			return;
		}
		if ( isset( $node['@type'] ) ) {
			$node_types = is_array( $node['@type'] ) ? $node['@type'] : array( $node['@type'] );
			foreach ( $node_types as $t ) {
				if ( is_string( $t ) && '' !== $t ) {
					$t           = self::clean_text( $t );
					$types[ $t ] = isset( $types[ $t ] ) ? $types[ $t ] + 1 : 1;
				}
			}
		}
		foreach ( $node as $value ) {
			if ( is_array( $value ) ) {
				self::collect_schema_types( $value, $types, $depth + 1 );
			}
		}
	}

	/**
	 * Does this robots signal say noindex?
	 *
	 * @param string $meta_robots  Content of the meta robots tag.
	 * @param string $x_robots_tag X-Robots-Tag header value.
	 * @return bool
	 */
	public static function is_noindex( $meta_robots, $x_robots_tag ) {
		$combined = strtolower( $meta_robots . ' ' . $x_robots_tag );
		return ( false !== strpos( $combined, 'noindex' ) || false !== strpos( $combined, 'none' ) );
	}

	/**
	 * Collapse whitespace, strip control characters, trim, cap length.
	 *
	 * @param string $text Raw text.
	 * @return string
	 */
	public static function clean_text( $text ) {
		$text = preg_replace( '/[\x00-\x1F\x7F]+/', ' ', (string) $text );
		$text = preg_replace( '/\s+/u', ' ', $text );
		if ( null === $text ) {
			return '';
		}
		$text = trim( $text );
		if ( strlen( $text ) > 500 ) {
			$text = substr( $text, 0, 500 );
		}
		return $text;
	}

	/**
	 * Stable hash of a field set (for cheap "anything changed?" checks).
	 *
	 * @param array $fields Field set from extract().
	 * @return string
	 */
	public static function hash_fields( $fields ) {
		return md5( wp_json_encode( $fields ) );
	}
}
