<?php
/**
 * URL safety validation (SSRF protection).
 *
 * @package BrokenLinkChecker
 * @since 1.1.0
 */

namespace BrokenLinkChecker;

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

final class UrlSafety {

	/** @var list<string> */
	private const BLOCKED_HOSTS = [
		'localhost',
		'localhost.localdomain',
		'metadata.google.internal',
		'metadata.google',
	];

	/**
	 * Whether an outbound HTTP request to this URL is allowed.
	 */
	public static function isAllowedUrl( string $url ): bool {
		$url = trim( $url );
		if ( $url === '' ) {
			return false;
		}

		$parsed = wp_parse_url( $url );
		if ( ! is_array( $parsed ) || empty( $parsed['host'] ) ) {
			return false;
		}

		$scheme = strtolower( (string) ( $parsed['scheme'] ?? '' ) );
		if ( $scheme !== '' && ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
			return false;
		}

		$host = strtolower( (string) $parsed['host'] );
		if ( self::isBlockedHost( $host ) ) {
			return false;
		}

		$ips = self::resolveHostIps( $host );
		if ( $ips === [] ) {
			// Fail closed: an unresolvable host cannot be verified as safe.
			return false;
		}

		foreach ( $ips as $ip ) {
			if ( self::isBlockedIp( $ip ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Whether the URL belongs to this WordPress site.
	 */
	public static function isInternalUrl( string $url ): bool {
		$url = trim( $url );
		if ( $url === '' ) {
			return false;
		}

		if ( str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) ) {
			return true;
		}

		$parsed = wp_parse_url( $url );
		if ( ! is_array( $parsed ) || empty( $parsed['host'] ) ) {
			return false;
		}

		$hosts = self::getSiteHosts();
		$host  = strtolower( (string) $parsed['host'] );
		$host  = self::stripWww( $host );

		foreach ( $hosts as $site_host ) {
			if ( $host === $site_host ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Normalize a relative URL to absolute using home_url().
	 */
	public static function normalizeUrl( string $url ): string {
		$url = trim( $url );
		if ( str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) ) {
			return home_url( $url );
		}
		if ( str_starts_with( $url, '//' ) ) {
			return ( is_ssl() ? 'https:' : 'http:' ) . $url;
		}
		return $url;
	}

	/**
	 * @return array<string>
	 */
	public static function getAllowedPostTypes(): array {
		return array_values( array_keys( get_post_types( [ 'public' => true ], 'names' ) ) );
	}

	/**
	 * @param array<string, mixed> $settings
	 * @return array<string, mixed>
	 */
	public static function normalizeSettings( array $settings ): array {
		$allow     = array_flip( self::getAllowedPostTypes() );
		$raw       = isset( $settings['post_types'] ) && is_array( $settings['post_types'] ) ? $settings['post_types'] : [];
		$clean     = [];
		foreach ( $raw as $slug ) {
			$k = sanitize_key( (string) $slug );
			if ( $k !== '' && isset( $allow[ $k ] ) ) {
				$clean[] = $k;
			}
		}
		$settings['post_types'] = array_values( array_unique( $clean ) );
		if ( $settings['post_types'] === [] ) {
			foreach ( [ 'post', 'page' ] as $fallback ) {
				if ( isset( $allow[ $fallback ] ) ) {
					$settings['post_types'][] = $fallback;
				}
			}
		}
		return $settings;
	}

	private static function isBlockedHost( string $host ): bool {
		$host = self::stripWww( strtolower( $host ) );
		if ( in_array( $host, self::BLOCKED_HOSTS, true ) ) {
			return true;
		}
		if ( str_ends_with( $host, '.internal' ) || str_ends_with( $host, '.local' ) ) {
			return true;
		}
		if ( filter_var( $host, FILTER_VALIDATE_IP ) && self::isBlockedIp( $host ) ) {
			return true;
		}
		return false;
	}

	private static function isBlockedIp( string $ip ): bool {
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return false;
		}
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return true;
		}
		// Link-local / cloud metadata.
		if ( $ip === '169.254.169.254' || str_starts_with( $ip, '169.254.' ) ) {
			return true;
		}
		return false;
	}

	/**
	 * @return list<string>
	 */
	private static function resolveHostIps( string $host ): array {
		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
			return [ $host ];
		}

		$ips = [];
		$a   = @gethostbynamel( $host ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( is_array( $a ) ) {
			foreach ( $a as $ip ) {
				$ips[] = (string) $ip;
			}
		}
		if ( function_exists( 'dns_get_record' ) ) {
			$aaaa = @dns_get_record( $host, DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( is_array( $aaaa ) ) {
				foreach ( $aaaa as $row ) {
					if ( ! empty( $row['ipv6'] ) ) {
						$ips[] = (string) $row['ipv6'];
					}
				}
			}
		}

		return array_values( array_unique( array_filter( $ips ) ) );
	}

	/**
	 * @return list<string>
	 */
	private static function getSiteHosts(): array {
		$hosts = [];
		foreach ( [ home_url(), site_url() ] as $site_url ) {
			$parsed = wp_parse_url( $site_url );
			if ( is_array( $parsed ) && ! empty( $parsed['host'] ) ) {
				$hosts[] = self::stripWww( strtolower( (string) $parsed['host'] ) );
			}
		}
		return array_values( array_unique( $hosts ) );
	}

	private static function stripWww( string $host ): string {
		return str_starts_with( $host, 'www.' ) ? substr( $host, 4 ) : $host;
	}
}
