<?php
/**
 * Same-site URL policy for published-page schema checks.
 *
 * @package SEOvaultSEO\Schema\Validation
 */

namespace SEOvaultSEO\Schema\Validation;

use SEOvaultSEO\Core\PostMeta;

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

/**
 * Restricts live schema fetches to safe same-site public post URLs.
 */
class LiveUrlPolicy {

	/**
	 * Evaluate whether a URL may be fetched for a post.
	 *
	 * @param string $url     Candidate URL.
	 * @param int    $post_id Post ID that owns the check.
	 * @return array{allowed:bool,code:string,message:string}
	 */
	public function evaluate( $url, $post_id ) {
		$url = is_string( $url ) ? trim( $url ) : '';
		if ( '' === $url ) {
			return $this->reject( 'empty_url', __( 'No public URL is available for this post.', 'seovault-ai' ) );
		}

		$parts = wp_parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return $this->reject( 'invalid_url', __( 'The URL is not a valid HTTP(S) address.', 'seovault-ai' ) );
		}

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

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

		if ( $this->contains_preview_or_nonce_query( $parts ) ) {
			return $this->reject( 'preview_rejected', __( 'Preview and authentication-gated URLs cannot be checked.', 'seovault-ai' ) );
		}

		$host = strtolower( (string) $parts['host'] );

		$site_parts = wp_parse_url( home_url( '/' ) );
		if ( ! is_array( $site_parts ) || empty( $site_parts['host'] ) ) {
			return $this->reject( 'site_origin_unavailable', __( 'The site origin could not be determined.', 'seovault-ai' ) );
		}

		$site_host   = strtolower( (string) $site_parts['host'] );
		$same_origin = $this->hosts_match( $host, $site_host );

		// Reject private/loopback/link-local hosts unless they are the site origin itself.
		if ( $this->is_disallowed_host( $host ) && ! $same_origin ) {
			return $this->reject( 'host_rejected', __( 'This host cannot be fetched for published-page validation.', 'seovault-ai' ) );
		}

		if ( ! $same_origin ) {
			return $this->reject( 'cross_origin', __( 'Only same-site URLs can be checked.', 'seovault-ai' ) );
		}

		$port_check = $this->evaluate_port( $parts, $scheme );
		if ( ! $port_check['allowed'] ) {
			return $port_check;
		}

		if ( ! $this->is_approved_post_url( $url, $post_id ) ) {
			return $this->reject( 'not_post_url', __( 'Only this post’s public permalink or approved canonical URL can be checked.', 'seovault-ai' ) );
		}

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

	/**
	 * Resolve the preferred public URL for a post when one is allowed.
	 *
	 * @param int $post_id Post ID.
	 * @return string
	 */
	public function resolve_post_url( $post_id ) {
		$permalink = get_permalink( $post_id );
		if ( is_string( $permalink ) && '' !== $permalink ) {
			$check = $this->evaluate( $permalink, $post_id );
			if ( $check['allowed'] ) {
				return $permalink;
			}
		}

		$canonical = PostMeta::get_canonical_url( $post_id );
		if ( is_string( $canonical ) && '' !== $canonical ) {
			$check = $this->evaluate( $canonical, $post_id );
			if ( $check['allowed'] ) {
				return $canonical;
			}
		}

		return '';
	}

	/**
	 * Whether the URL is the post permalink or an approved same-origin canonical.
	 *
	 * @param string $url     Candidate URL.
	 * @param int    $post_id Post ID.
	 * @return bool
	 */
	private function is_approved_post_url( $url, $post_id ) {
		$normalized = $this->normalize_url( $url );
		$permalink  = get_permalink( $post_id );
		if ( is_string( $permalink ) && '' !== $permalink && $this->normalize_url( $permalink ) === $normalized ) {
			return true;
		}

		$canonical = PostMeta::get_canonical_url( $post_id );
		if ( ! is_string( $canonical ) || '' === $canonical ) {
			return false;
		}

		if ( $this->normalize_url( $canonical ) !== $normalized ) {
			return false;
		}

		$resolved = function_exists( 'url_to_postid' ) ? (int) url_to_postid( $canonical ) : 0;
		if ( $resolved > 0 ) {
			return $resolved === (int) $post_id;
		}

		// Canonical may be an absolute same-origin alias not mapped by url_to_postid.
		return $this->normalize_url( $canonical ) === $normalized;
	}

	/**
	 * Reject preview / authentication query markers.
	 *
	 * @param array $parts Parsed URL parts.
	 * @return bool
	 */
	private function contains_preview_or_nonce_query( $parts ) {
		if ( empty( $parts['query'] ) || ! is_string( $parts['query'] ) ) {
			return false;
		}

		parse_str( $parts['query'], $query );
		if ( ! is_array( $query ) ) {
			return false;
		}

		$blocked = array( 'preview', 'preview_id', 'preview_nonce', '_wpnonce' );
		foreach ( $blocked as $key ) {
			if ( array_key_exists( $key, $query ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Reject loopback, link-local, private, and reserved hosts/IPs.
	 *
	 * @param string $host Hostname or IP literal.
	 * @return bool
	 */
	private function is_disallowed_host( $host ) {
		$host = strtolower( trim( $host, '[]' ) );
		if ( '' === $host ) {
			return true;
		}

		if ( in_array( $host, array( 'localhost', 'localhost.localdomain' ), true ) ) {
			return true;
		}

		if ( 0 === strpos( $host, 'localhost.' ) ) {
			return true;
		}

		if ( filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			return $this->is_disallowed_ipv4( $host );
		}

		if ( filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			return $this->is_disallowed_ipv6( $host );
		}

		return false;
	}

	/**
	 * @param string $ip IPv4 address.
	 * @return bool
	 */
	private function is_disallowed_ipv4( $ip ) {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return true;
		}

		$long = ip2long( $ip );
		if ( false === $long ) {
			return true;
		}

		// 169.254.0.0/16 link-local (also covered by NO_RES_RANGE on most builds).
		if ( ( $long & 0xffff0000 ) === 0xa9fe0000 ) {
			return true;
		}

		return false;
	}

	/**
	 * @param string $ip IPv6 address.
	 * @return bool
	 */
	private function is_disallowed_ipv6( $ip ) {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return true;
		}

		$packed = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid IPv6 must not fatal.
		if ( false === $packed ) {
			return true;
		}

		// ::1 loopback.
		if ( $packed === inet_pton( '::1' ) ) {
			return true;
		}

		// fe80::/10 link-local.
		$ord0 = ord( $packed[0] );
		$ord1 = ord( $packed[1] );
		if ( 0xfe === $ord0 && ( $ord1 & 0xc0 ) === 0x80 ) {
			return true;
		}

		return false;
	}

	/**
	 * Only default scheme ports (or omitted ports) are allowed.
	 *
	 * @param array  $parts  Parsed URL.
	 * @param string $scheme http|https.
	 * @return array{allowed:bool,code:string,message:string}
	 */
	private function evaluate_port( $parts, $scheme ) {
		if ( ! isset( $parts['port'] ) ) {
			return array(
				'allowed' => true,
				'code'    => 'allowed',
				'message' => '',
			);
		}

		$port     = (int) $parts['port'];
		$expected = 'https' === $scheme ? 443 : 80;
		if ( $port !== $expected ) {
			return $this->reject( 'port_rejected', __( 'Unexpected URL ports are not allowed.', 'seovault-ai' ) );
		}

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

	/**
	 * Compare hosts, treating www and bare host as equivalent for the site.
	 *
	 * @param string $left  Host A.
	 * @param string $right Host B.
	 * @return bool
	 */
	private function hosts_match( $left, $right ) {
		$left  = preg_replace( '/^www\\./', '', strtolower( $left ) );
		$right = preg_replace( '/^www\\./', '', strtolower( $right ) );

		return $left === $right;
	}

	/**
	 * Normalize a URL for equality checks.
	 *
	 * @param string $url URL.
	 * @return string
	 */
	private function normalize_url( $url ) {
		$parts = wp_parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return '';
		}

		$scheme = strtolower( (string) $parts['scheme'] );
		$host   = strtolower( (string) $parts['host'] );
		$host   = preg_replace( '/^www\\./', '', $host );
		$path   = isset( $parts['path'] ) ? $parts['path'] : '/';
		$path   = untrailingslashit( $path );
		if ( '' === $path ) {
			$path = '/';
		}

		$query = '';
		if ( ! empty( $parts['query'] ) ) {
			$query = '?' . $parts['query'];
		}

		return $scheme . '://' . $host . $path . $query;
	}

	/**
	 * @param string $code    Machine code.
	 * @param string $message Human message.
	 * @return array{allowed:bool,code:string,message:string}
	 */
	private function reject( $code, $message ) {
		return array(
			'allowed' => false,
			'code'    => $code,
			'message' => $message,
		);
	}
}
