<?php
/**
 * SSRF-hardened same-site HTML fetcher for live schema checks.
 *
 * @package SEOvaultSEO\Schema\Validation
 */

namespace SEOvaultSEO\Schema\Validation;

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

/**
 * Fetches public HTML with strict same-site controls.
 */
class LivePageFetcher {

	const TIMEOUT_SECONDS     = 6;
	const MAX_REDIRECTS       = 3;
	const MAX_BODY_BYTES      = 2097152;
	const CACHE_TTL_SECONDS   = 60;
	const RATE_LIMIT_SECONDS  = 30;
	const USER_AGENT          = 'SEOvaultAI-SchemaHealth/1.0 (+https://seovaultai.com)';

	/**
	 * URL policy.
	 *
	 * @var LiveUrlPolicy
	 */
	private $policy;

	/**
	 * @param LiveUrlPolicy|null $policy Optional policy.
	 */
	public function __construct( $policy = null ) {
		$this->policy = $policy instanceof LiveUrlPolicy ? $policy : new LiveUrlPolicy();
	}

	/**
	 * Fetch HTML for a validated same-site post URL.
	 *
	 * @param string $url     URL to fetch.
	 * @param int    $post_id Post ID for policy and rate limiting.
	 * @return array{ok:bool,code:string,message:string,body:string,final_url:string,status:int}
	 */
	public function fetch( $url, $post_id ) {
		$policy = $this->policy->evaluate( $url, $post_id );
		if ( ! $policy['allowed'] ) {
			return $this->failure( $policy['code'], $policy['message'] );
		}

		$cache_key = $this->cache_key( $url, $post_id );
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) && isset( $cached['ok'] ) ) {
			return $cached;
		}

		$rate = $this->check_rate_limit( $post_id );
		if ( ! $rate['allowed'] ) {
			return $this->failure( 'rate_limited', $rate['message'] );
		}

		$result = $this->request_with_redirects( $url, $post_id, 0 );
		set_transient( $cache_key, $result, self::CACHE_TTL_SECONDS );
		$this->mark_rate_limit( $post_id );

		return $result;
	}

	/**
	 * Follow redirects manually so each Location is re-validated.
	 *
	 * @param string $url           Current URL.
	 * @param int    $post_id       Post ID.
	 * @param int    $redirect_count Redirects followed so far.
	 * @return array{ok:bool,code:string,message:string,body:string,final_url:string,status:int}
	 */
	private function request_with_redirects( $url, $post_id, $redirect_count ) {
		$policy = $this->policy->evaluate( $url, $post_id );
		if ( ! $policy['allowed'] ) {
			return $this->failure( $policy['code'], $policy['message'] );
		}

		$response = wp_safe_remote_get(
			$url,
			array(
				'timeout'             => self::TIMEOUT_SECONDS,
				'redirection'         => 0,
				'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',
					'User-Agent' => self::USER_AGENT,
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			$code = $response->get_error_code();
			if ( in_array( $code, array( 'http_request_failed', 'http_failure', 'connect_timeout', 'request_timed_out' ), true )
				|| false !== strpos( strtolower( $response->get_error_message() ), 'timed out' )
			) {
				return $this->failure( 'timeout', __( 'The published page could not be reached in time.', 'seovault-ai' ) );
			}

			return $this->failure( 'http_error', __( 'The published page could not be fetched.', 'seovault-ai' ) );
		}

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

		if ( $status >= 300 && $status < 400 && '' !== $location ) {
			if ( $redirect_count >= self::MAX_REDIRECTS ) {
				return $this->failure( 'too_many_redirects', __( 'Too many redirects while fetching the published page.', 'seovault-ai' ) );
			}

			$next = $this->absolutize_url( $location, $url );
			$next_policy = $this->policy->evaluate( $next, $post_id );
			if ( ! $next_policy['allowed'] ) {
				return $this->failure( $next_policy['code'], $next_policy['message'] );
			}

			return $this->request_with_redirects( $next, $post_id, $redirect_count + 1 );
		}

		if ( $status < 200 || $status >= 300 ) {
			return $this->failure(
				'http_status',
				sprintf(
					/* translators: %d: HTTP status code. */
					__( 'The published page returned HTTP %d.', 'seovault-ai' ),
					$status
				),
				$status
			);
		}

		$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_html_content_type( $content_type ) ) {
			return $this->failure( 'content_type', __( 'The response was not HTML.', 'seovault-ai' ), $status );
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( strlen( $body ) > self::MAX_BODY_BYTES ) {
			return $this->failure( 'body_too_large', __( 'The published page response was too large.', 'seovault-ai' ), $status );
		}

		return array(
			'ok'        => true,
			'code'      => 'ok',
			'message'   => '',
			'body'      => $body,
			'final_url' => $url,
			'status'    => $status,
		);
	}

	/**
	 * @param string $content_type Content-Type header.
	 * @return bool
	 */
	private function is_html_content_type( $content_type ) {
		$content_type = strtolower( trim( explode( ';', $content_type )[0] ) );
		if ( '' === $content_type ) {
			// Some servers omit Content-Type; allow and rely on HTML extraction.
			return true;
		}

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

	/**
	 * Resolve a possibly relative redirect Location against the previous URL.
	 *
	 * @param string $location Location header.
	 * @param string $current  Current URL.
	 * @return string
	 */
	private function absolutize_url( $location, $current ) {
		$location = trim( $location );
		if ( '' === $location ) {
			return '';
		}

		if ( preg_match( '#^https?://#i', $location ) ) {
			return $location;
		}

		$parts = wp_parse_url( $current );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return $location;
		}

		$origin = $parts['scheme'] . '://' . $parts['host'];
		if ( isset( $parts['port'] ) ) {
			$origin .= ':' . (int) $parts['port'];
		}

		if ( 0 === strpos( $location, '//' ) ) {
			return $parts['scheme'] . ':' . $location;
		}

		if ( 0 === strpos( $location, '/' ) ) {
			return $origin . $location;
		}

		$base_path = isset( $parts['path'] ) ? $parts['path'] : '/';
		$base_dir  = preg_replace( '#/[^/]*$#', '/', $base_path );

		return $origin . $base_dir . $location;
	}

	/**
	 * @param int $post_id Post ID.
	 * @return array{allowed:bool,message:string}
	 */
	private function check_rate_limit( $post_id ) {
		$key = $this->rate_limit_key( $post_id );
		if ( false !== get_transient( $key ) ) {
			return array(
				'allowed' => false,
				'message' => __( 'Please wait a moment before checking the published page again.', 'seovault-ai' ),
			);
		}

		return array(
			'allowed' => true,
			'message' => '',
		);
	}

	/**
	 * @param int $post_id Post ID.
	 * @return void
	 */
	private function mark_rate_limit( $post_id ) {
		set_transient( $this->rate_limit_key( $post_id ), 1, self::RATE_LIMIT_SECONDS );
	}

	/**
	 * @param int $post_id Post ID.
	 * @return string
	 */
	private function rate_limit_key( $post_id ) {
		$user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0;

		return 'seovault_schema_live_rl_' . $user_id . '_' . (int) $post_id;
	}

	/**
	 * @param string $url     URL.
	 * @param int    $post_id Post ID.
	 * @return string
	 */
	private function cache_key( $url, $post_id ) {
		return 'seovault_schema_live_fetch_' . (int) $post_id . '_' . md5( $url );
	}

	/**
	 * @param string $code    Code.
	 * @param string $message Message.
	 * @param int    $status  HTTP status.
	 * @return array{ok:bool,code:string,message:string,body:string,final_url:string,status:int}
	 */
	private function failure( $code, $message, $status = 0 ) {
		return array(
			'ok'        => false,
			'code'      => $code,
			'message'   => $message,
			'body'      => '',
			'final_url' => '',
			'status'    => (int) $status,
		);
	}
}
