<?php

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

/**
 * One outbound HTTP trust boundary for user/model-controlled URLs.
 */
final class Niwa_AI_Safe_HTTP {
	const DEFAULT_MAX_BYTES = 10485760; // 10 MB.
	const MAX_REDIRECTS      = 5;

	/**
	 * Validate an HTTP URL and every address currently returned by DNS.
	 *
	 * @param string $url URL to validate.
	 * @return string|WP_Error Normalized URL or validation error.
	 */
	public static function validate_url( $url ) {
		$url   = trim( (string) $url );
		if ( '' === $url || preg_match( '/[\x00-\x20\x7f]/', $url ) ) {
			return new WP_Error( 'niwa_ssrf', 'URL is empty or contains control characters.' );
		}
		$parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url );
		if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return new WP_Error( 'niwa_ssrf', 'URL must include an http/https scheme and host.' );
		}

		$scheme = strtolower( (string) $parts['scheme'] );
		$host   = strtolower( trim( (string) $parts['host'], '[]' ) );
		if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
			return new WP_Error( 'niwa_ssrf', 'Only credential-free http/https URLs are allowed.' );
		}
		if ( '' === $host || 'localhost' === $host || preg_match( '/(^|\.)localhost$|(^|\.)local$/i', $host ) ) {
			return new WP_Error( 'niwa_ssrf', 'Local hostnames are not allowed.' );
		}

		$addresses = self::resolve_host( $host );
		if ( empty( $addresses ) ) {
			return new WP_Error( 'niwa_ssrf', 'Hostname ' . $host . ' could not be resolved.' );
		}
		foreach ( $addresses as $address ) {
			if ( ! self::is_public_ip( $address ) ) {
				return new WP_Error( 'niwa_ssrf', 'Hostname ' . $host . ' resolves to a non-public address.' );
			}
		}

		return $url;
	}

	/**
	 * GET a URL while validating every redirect and enforcing a response cap.
	 *
	 * @param string $url       URL to request.
	 * @param array  $args      WordPress HTTP arguments.
	 * @param int    $max_bytes Maximum response bytes.
	 * @return array|WP_Error
	 */
	public static function get( $url, $args = array(), $max_bytes = self::DEFAULT_MAX_BYTES ) {
		return self::request( $url, $args, max( 1, intval( $max_bytes ) ), '' );
	}

	/**
	 * Stream a URL to a caller-owned file with the same trust boundary.
	 *
	 * @param string $url       URL to request.
	 * @param string $filename  Destination file.
	 * @param array  $args      WordPress HTTP arguments.
	 * @param int    $max_bytes Maximum downloaded bytes.
	 * @return array|WP_Error
	 */
	public static function download( $url, $filename, $args = array(), $max_bytes = self::DEFAULT_MAX_BYTES ) {
		$filename = (string) $filename;
		if ( '' === $filename ) {
			return new WP_Error( 'niwa_http_destination', 'Download destination is required.' );
		}

		return self::request( $url, $args, max( 1, intval( $max_bytes ) ), $filename );
	}

	private static function request( $url, $args, $max_bytes, $filename ) {
		if ( ! function_exists( 'wp_remote_get' ) ) {
			return new WP_Error( 'niwa_http_unavailable', 'WordPress HTTP client is unavailable.' );
		}

		$args          = is_array( $args ) ? $args : array();
		$max_redirects = min( self::MAX_REDIRECTS, max( 0, intval( isset( $args['redirection'] ) ? $args['redirection'] : 3 ) ) );
		unset( $args['redirection'], $args['sslverify'], $args['reject_unsafe_urls'], $args['stream'], $args['filename'], $args['limit_response_size'] );
		$args = array_merge(
			array( 'timeout' => 30 ),
			$args,
			array(
				'redirection'         => 0,
				'sslverify'           => true,
				'reject_unsafe_urls'  => true,
				'limit_response_size' => $max_bytes + 1,
			)
		);
		if ( '' !== $filename ) {
			$args['stream']   = true;
			$args['filename'] = $filename;
		}

		$current = (string) $url;
		for ( $redirect = 0; $redirect <= $max_redirects; $redirect++ ) {
			$current = self::validate_url( $current );
			if ( is_wp_error( $current ) ) {
				return self::cleanup_error( $current, $filename );
			}
			if ( '' !== $filename && is_file( $filename ) ) {
				@unlink( $filename );
			}

			$response = wp_remote_get( $current, $args );
			if ( is_wp_error( $response ) ) {
				return self::cleanup_error( $response, $filename );
			}

			$code = intval( wp_remote_retrieve_response_code( $response ) );
			if ( in_array( $code, array( 301, 302, 303, 307, 308 ), true ) ) {
				if ( $redirect >= $max_redirects ) {
					return self::cleanup_error( new WP_Error( 'niwa_http_redirect_limit', 'Remote URL exceeded the redirect limit.' ), $filename );
				}
				$location = self::response_header( $response, 'location' );
				if ( '' === $location ) {
					return self::cleanup_error( new WP_Error( 'niwa_http_redirect', 'Redirect response omitted Location.' ), $filename );
				}
				$current = self::resolve_redirect( $current, $location );
				continue;
			}

			$size = '' !== $filename && is_file( $filename )
				? intval( filesize( $filename ) )
				: strlen( (string) wp_remote_retrieve_body( $response ) );
			if ( $size > $max_bytes ) {
				if ( '' !== $filename ) {
					@unlink( $filename );
				}
				return new WP_Error( 'niwa_http_too_large', 'Remote response exceeds the allowed size.' );
			}

			return $response;
		}

		return self::cleanup_error( new WP_Error( 'niwa_http_redirect_limit', 'Remote URL exceeded the redirect limit.' ), $filename );
	}

	private static function cleanup_error( $error, $filename ) {
		if ( '' !== (string) $filename && is_file( (string) $filename ) ) {
			@unlink( (string) $filename );
		}
		return $error;
	}

	private static function resolve_host( $host ) {
		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
			return array( $host );
		}

		$filtered = apply_filters( 'niwa_ai_safe_http_resolve_hostname', null, $host );
		if ( null === $filtered ) {
			// Backward-compatible test/host integration hook.
			$filtered = apply_filters( 'niwa_ai_ssrf_resolve_hostname', null, $host );
		}
		if ( null !== $filtered ) {
			return array_values( array_unique( array_filter( array_map( 'strval', (array) $filtered ) ) ) );
		}

		$addresses = array();
		if ( function_exists( 'dns_get_record' ) ) {
			$records = @dns_get_record( $host, DNS_A | DNS_AAAA );
			foreach ( is_array( $records ) ? $records : array() as $record ) {
				if ( ! empty( $record['ip'] ) ) {
					$addresses[] = (string) $record['ip'];
				}
				if ( ! empty( $record['ipv6'] ) ) {
					$addresses[] = (string) $record['ipv6'];
				}
			}
		}
		if ( empty( $addresses ) && function_exists( 'gethostbynamel' ) ) {
			$resolved  = @gethostbynamel( $host );
			$addresses = is_array( $resolved ) ? $resolved : array();
		}

		return array_values( array_unique( $addresses ) );
	}

	private static function is_public_ip( $ip ) {
		return false !== filter_var(
			trim( (string) $ip ),
			FILTER_VALIDATE_IP,
			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
		);
	}

	private static function response_header( $response, $name ) {
		if ( function_exists( 'wp_remote_retrieve_header' ) ) {
			return trim( (string) wp_remote_retrieve_header( $response, $name ) );
		}
		$headers = isset( $response['headers'] ) ? $response['headers'] : array();
		if ( is_object( $headers ) && method_exists( $headers, 'offsetGet' ) ) {
			return trim( (string) $headers->offsetGet( $name ) );
		}
		foreach ( is_array( $headers ) ? $headers : array() as $key => $value ) {
			if ( strtolower( (string) $key ) === strtolower( (string) $name ) ) {
				return trim( (string) $value );
			}
		}
		return '';
	}

	private static function resolve_redirect( $base, $location ) {
		$location = trim( (string) $location );
		if ( preg_match( '#^https?://#i', $location ) ) {
			return $location;
		}
		$base_parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $base ) : parse_url( $base );
		if ( ! is_array( $base_parts ) || empty( $base_parts['scheme'] ) || empty( $base_parts['host'] ) ) {
			return $location;
		}
		$authority = $base_parts['scheme'] . '://' . $base_parts['host'];
		if ( ! empty( $base_parts['port'] ) ) {
			$authority .= ':' . intval( $base_parts['port'] );
		}
		if ( 0 === strpos( $location, '//' ) ) {
			return $base_parts['scheme'] . ':' . $location;
		}
		if ( 0 === strpos( $location, '/' ) ) {
			return $authority . $location;
		}

		$path     = isset( $base_parts['path'] ) ? (string) $base_parts['path'] : '/';
		$segments = explode( '/', preg_replace( '#/[^/]*$#', '/', $path ) . $location );
		$out      = array();
		foreach ( $segments as $segment ) {
			if ( '' === $segment || '.' === $segment ) {
				continue;
			}
			if ( '..' === $segment ) {
				array_pop( $out );
				continue;
			}
			$out[] = $segment;
		}
		return $authority . '/' . implode( '/', $out );
	}
}
