<?php
/**
 * Schema Parser
 *
 * Parses JSON-LD schema from external URLs or raw HTML — useful for
 * the "import from URL" feature in the schema builder.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use WP_Error;
use DOMDocument;
use DOMXPath;

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

/**
 * Fetches and parses remote JSON-LD with SSRF-hardened HTTP controls.
 */
class SchemaParser {

	/**
	 * Outbound request timeout (seconds).
	 */
	const TIMEOUT_SECONDS = 10;

	/**
	 * Max redirects allowed for import fetches.
	 */
	const MAX_REDIRECTS = 3;

	/**
	 * Max HTML body bytes accepted for parse/fetch (2 MiB).
	 */
	const MAX_BODY_BYTES = 2097152;

	/**
	 * Fetch and parse JSON-LD from a URL.
	 *
	 * @param string $url URL to fetch.
	 * @return array|WP_Error Parsed schema array or error.
	 */
	public function from_url( $url ) {
		$url = esc_url_raw( is_string( $url ) ? $url : '' );
		if ( empty( $url ) ) {
			return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'seovault-ai' ) );
		}

		$policy = $this->evaluate_url_policy( $url );
		if ( is_wp_error( $policy ) ) {
			return $policy;
		}

		$response = wp_safe_remote_get(
			$url,
			array(
				'timeout'             => self::TIMEOUT_SECONDS,
				'redirection'         => self::MAX_REDIRECTS,
				'limit_response_size' => self::MAX_BODY_BYTES,
				'sslverify'           => true,
				'cookies'             => array(),
				'headers'             => array(
					'Accept' => 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$status_code = (int) wp_remote_retrieve_response_code( $response );
		if ( $status_code < 200 || $status_code >= 300 ) {
			return new WP_Error(
				'schema_fetch_failed',
				sprintf(
					/* translators: %d: HTTP status code. */
					__( 'The URL could not be fetched (HTTP %d).', 'seovault-ai' ),
					$status_code
				)
			);
		}

		$headers      = wp_remote_retrieve_headers( $response );
		$content_type = '';
		if ( is_object( $headers ) && method_exists( $headers, 'offsetGet' ) ) {
			$content_type = (string) $headers->offsetGet( 'content-type' );
		} elseif ( is_array( $headers ) && isset( $headers['content-type'] ) ) {
			$content_type = is_array( $headers['content-type'] ) ? (string) end( $headers['content-type'] ) : (string) $headers['content-type'];
		}

		if ( ! $this->is_allowed_content_type( $content_type ) ) {
			return new WP_Error( 'content_type', __( 'The response was not HTML.', 'seovault-ai' ) );
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === $body ) {
			return new WP_Error( 'body_not_found', __( 'No HTML body found at the URL.', 'seovault-ai' ) );
		}

		if ( strlen( $body ) > self::MAX_BODY_BYTES ) {
			return new WP_Error( 'body_too_large', __( 'The remote page response was too large.', 'seovault-ai' ) );
		}

		return $this->from_html( $body );
	}

	/**
	 * Parse JSON-LD script tags from raw HTML.
	 *
	 * @param string $html HTML content.
	 * @return array Array of decoded JSON-LD objects.
	 */
	public function from_html( $html ) {
		if ( ! is_string( $html ) || '' === $html ) {
			return array();
		}

		if ( strlen( $html ) > self::MAX_BODY_BYTES ) {
			return array();
		}

		libxml_use_internal_errors( true );

		$dom = new DOMDocument();
		$dom->loadHTML( $html );

		$xpath   = new DOMXPath( $dom );
		$scripts = $xpath->query( '//script[@type="application/ld+json"]' );

		$json = array();
		foreach ( $scripts as $script ) {
			$data = json_decode( trim( $script->nodeValue ), true );
			if ( is_array( $data ) ) {
				$json[] = $data;
			}
		}

		libxml_clear_errors();

		return $json;
	}

	/**
	 * Reject non-HTTP(S), credentialed, and WordPress-unsafe URLs before fetch.
	 *
	 * @param string $url Candidate URL (already passed through esc_url_raw).
	 * @return true|WP_Error
	 */
	private function evaluate_url_policy( $url ) {
		$parts = wp_parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return new WP_Error( 'invalid_url', __( 'Invalid URL provided.', 'seovault-ai' ) );
		}

		$scheme = strtolower( (string) $parts['scheme'] );
		if ( ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
			return new WP_Error( 'scheme_rejected', __( 'Only HTTP and HTTPS URLs are allowed.', 'seovault-ai' ) );
		}

		if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
			return new WP_Error( 'credentials_rejected', __( 'URLs with credentials are not allowed.', 'seovault-ai' ) );
		}

		// Mirror wp_safe_remote_get private/loopback rejection with an explicit error code.
		if ( function_exists( 'wp_http_validate_url' ) && false === wp_http_validate_url( $url ) ) {
			return new WP_Error( 'unsafe_url', __( 'This URL cannot be fetched safely.', 'seovault-ai' ) );
		}

		return true;
	}

	/**
	 * Allow HTML-like Content-Types; empty type is accepted (some servers omit it).
	 *
	 * @param string $content_type Content-Type header.
	 * @return bool
	 */
	private function is_allowed_content_type( $content_type ) {
		$content_type = strtolower( trim( explode( ';', (string) $content_type )[0] ) );
		if ( '' === $content_type ) {
			return true;
		}

		return in_array(
			$content_type,
			array(
				'text/html',
				'application/xhtml+xml',
				'application/xml',
				'text/xml',
			),
			true
		);
	}
}
