<?php
/**
 * NeccoSpeak Phase 6 SSRF-hardened HTTP client (design phase6 §3, G10).
 *
 * The single entry point for any outbound HTTP request this plugin makes.
 * Enforces: https-only (with a narrow same-site http exception for
 * dev/intranet installs), a fixed host allow-list, DNS resolution followed
 * by a public/private IP check on every resolved address, and redirect
 * re-validation on every hop (WP core's own redirect-following is disabled
 * and replaced with a manual loop that revalidates the Location header
 * before each hop).
 *
 * Phase 6's only in-tree caller is G15's same-site URL existence check
 * (includes/page-context.php, via neccospeak_http_validate_url()). The
 * OpenAI calls in includes/rest.php are unchanged (§3.0) -- this file exists
 * so that any future "fetch an arbitrary URL" feature has exactly one place
 * to plug into, already carrying the SSRF defenses.
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/* -----------------------------------------------------------------------
 * §3.1 constants
 * -------------------------------------------------------------------- */

if ( ! defined( 'NECCOSPEAK_HTTP_TIMEOUT_DEFAULT' ) ) {
	define( 'NECCOSPEAK_HTTP_TIMEOUT_DEFAULT', 5 );
}
if ( ! defined( 'NECCOSPEAK_HTTP_MAX_REDIRECTS' ) ) {
	define( 'NECCOSPEAK_HTTP_MAX_REDIRECTS', 3 );
}
if ( ! defined( 'NECCOSPEAK_HTTP_MAX_URL_BYTES' ) ) {
	define( 'NECCOSPEAK_HTTP_MAX_URL_BYTES', 2048 );
}
if ( ! defined( 'NECCOSPEAK_HTTP_MAX_BODY_BYTES' ) ) {
	define( 'NECCOSPEAK_HTTP_MAX_BODY_BYTES', 262144 );
}
if ( ! defined( 'NECCOSPEAK_HTTP_LAST_ERROR_OPTION' ) ) {
	define( 'NECCOSPEAK_HTTP_LAST_ERROR_OPTION', 'neccospeak_http_last_error' );
}

/* -----------------------------------------------------------------------
 * Internal helpers (not part of the §2.1 public contract).
 * -------------------------------------------------------------------- */

/**
 * Builds a WP_Error for a validation/request failure and records it as the
 * single "last error" diagnostic (§3.6): autoload=no, {code, at} only --
 * never the URL itself.
 *
 * @param string $code    One of the §3.6 error codes.
 * @param string $message Human-readable message (English primary).
 * @return WP_Error
 */
function neccospeak_http_error( $code, $message ) {
	if ( function_exists( 'update_option' ) ) {
		update_option(
			NECCOSPEAK_HTTP_LAST_ERROR_OPTION,
			array(
				'code' => $code,
				'at'   => function_exists( 'current_time' ) ? current_time( 'mysql' ) : gmdate( 'Y-m-d H:i:s' ),
			),
			false
		);
	}
	return new WP_Error( $code, $message );
}

/**
 * Whether a binary IP address (as returned by inet_pton()) falls inside a
 * CIDR range, given as a binary network prefix of the same address family
 * and a prefix bit length.
 *
 * @param string $bin        Binary IP (4 or 16 bytes).
 * @param string $prefix_bin Binary network address, same length as $bin.
 * @param int    $bits       Prefix length in bits.
 * @return bool
 */
function neccospeak_http_bin_in_range( $bin, $prefix_bin, $bits ) {
	$full_bytes = intdiv( $bits, 8 );
	$rem_bits   = $bits % 8;

	if ( $full_bytes > 0 && substr( $bin, 0, $full_bytes ) !== substr( $prefix_bin, 0, $full_bytes ) ) {
		return false;
	}
	if ( $rem_bits > 0 ) {
		$mask = ( 0xFF << ( 8 - $rem_bits ) ) & 0xFF;
		if ( ( ord( $bin[ $full_bytes ] ) & $mask ) !== ( ord( $prefix_bin[ $full_bytes ] ) & $mask ) ) {
			return false;
		}
	}
	return true;
}

/**
 * §3.3 IPv4 private/reserved range table.
 *
 * @param string $bin 4-byte binary IPv4 address.
 * @return bool
 */
function neccospeak_http_ipv4_private_bin( $bin ) {
	static $ranges = null;
	if ( null === $ranges ) {
		$ranges = array(
			array( '0.0.0.0', 8 ),
			array( '10.0.0.0', 8 ),
			array( '100.64.0.0', 10 ),
			array( '127.0.0.0', 8 ),
			array( '169.254.0.0', 16 ),
			array( '172.16.0.0', 12 ),
			array( '192.0.0.0', 24 ),
			array( '192.0.2.0', 24 ),
			array( '192.88.99.0', 24 ),
			array( '192.168.0.0', 16 ),
			array( '198.18.0.0', 15 ),
			array( '198.51.100.0', 24 ),
			array( '203.0.113.0', 24 ),
			array( '224.0.0.0', 4 ),
			array( '240.0.0.0', 4 ),
		);
	}
	foreach ( $ranges as $range ) {
		$net_bin = inet_pton( $range[0] );
		if ( false !== $net_bin && neccospeak_http_bin_in_range( $bin, $net_bin, $range[1] ) ) {
			return true;
		}
	}
	return false;
}

/**
 * §3.3 IPv6 private/reserved range table, including the three embedded-IPv4
 * forms (IPv4-mapped, NAT64, 6to4) which are re-judged against the IPv4
 * table.
 *
 * @param string $bin 16-byte binary IPv6 address.
 * @return bool
 */
function neccospeak_http_ipv6_private_bin( $bin ) {
	static $ranges = null;
	if ( null === $ranges ) {
		$ranges = array(
			array( '::', 128 ),
			array( '::1', 128 ),
			array( 'fc00::', 7 ),
			array( 'fe80::', 10 ),
			array( 'ff00::', 8 ),
			array( '100::', 64 ),
			array( '2001:db8::', 32 ),
		);
	}
	foreach ( $ranges as $range ) {
		$net_bin = inet_pton( $range[0] );
		if ( false !== $net_bin && neccospeak_http_bin_in_range( $bin, $net_bin, $range[1] ) ) {
			return true;
		}
	}

	// ::ffff:0:0/96 -- IPv4-mapped: last 4 bytes carry the embedded IPv4.
	$v4mapped = inet_pton( '::ffff:0:0' );
	if ( false !== $v4mapped && neccospeak_http_bin_in_range( $bin, $v4mapped, 96 ) ) {
		return neccospeak_http_ipv4_private_bin( substr( $bin, 12, 4 ) );
	}

	// 64:ff9b::/96 -- NAT64: same embedding position as above.
	$nat64 = inet_pton( '64:ff9b::' );
	if ( false !== $nat64 && neccospeak_http_bin_in_range( $bin, $nat64, 96 ) ) {
		return neccospeak_http_ipv4_private_bin( substr( $bin, 12, 4 ) );
	}

	// 2002::/16 -- 6to4: the 32 bits right after the 2002:: prefix (bytes
	// 2-5) carry the embedded IPv4.
	$sixtofour = inet_pton( '2002::' );
	if ( false !== $sixtofour && neccospeak_http_bin_in_range( $bin, $sixtofour, 16 ) ) {
		return neccospeak_http_ipv4_private_bin( substr( $bin, 2, 4 ) );
	}

	return false;
}

/**
 * Detects alternate IPv4 notations (decimal, octal, hex, or partial a.b /
 * a.b.c shorthand) that some resolvers accept but the strict dotted-quad
 * regex in §3.3 does not. Used only for hosts that already failed the
 * strict-literal check, to reject them outright instead of handing them to
 * DNS resolution and trusting the resolver not to interpret them as an IP
 * (§3.4's closing note: "reject outright, don't resolve-then-judge").
 *
 * @param string $host Lower-cased host (never bracketed -- checked earlier).
 * @return bool
 */
function neccospeak_http_looks_like_alt_ipv4( $host ) {
	$segments = explode( '.', $host );
	foreach ( $segments as $seg ) {
		if ( '' === $seg ) {
			return false;
		}
		if ( preg_match( '/^0x[0-9a-fA-F]+$/', $seg ) ) {
			return true; // Hex octet, e.g. "0x7f.1".
		}
		if ( ! preg_match( '/^[0-9]+$/', $seg ) ) {
			return false; // Contains a genuine hostname character.
		}
	}
	return true; // Every segment was purely decimal digits (or a bare number).
}

/* -----------------------------------------------------------------------
 * §2.1 public contract
 * -------------------------------------------------------------------- */

/**
 * §3.7 -- the outbound host allow-list. NeccoSpeak itself never registers a
 * callback on the filter; anything a site adds becomes reachable from the
 * server, so treat it as a security boundary (see the filter's own PHPDoc).
 *
 * @return string[] Lower-case host names, deduplicated.
 */
function neccospeak_http_allowed_hosts() {
	$hosts = array( 'api.openai.com' );
	$site  = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
	if ( '' !== $site ) {
		$hosts[] = $site;
	}

	/**
	 * Filters the outbound host allow-list.
	 *
	 * NeccoSpeak itself never registers a callback here. Anything added by a
	 * site becomes reachable from the server -- treat it as a security
	 * boundary.
	 *
	 * @param string[] $hosts Lower-case host names.
	 */
	$hosts = apply_filters( 'neccospeak_http_allowed_hosts', $hosts );

	$out = array();
	foreach ( (array) $hosts as $h ) {
		if ( ! is_string( $h ) ) {
			continue;
		}
		$h = strtolower( rtrim( trim( $h ), '.' ) );
		// A wildcard / path / scheme in the allow-list is a configuration
		// error: silently drop it rather than accidentally matching more
		// than intended.
		if ( '' === $h || preg_match( '#[/*:\s]#', $h ) ) {
			continue;
		}
		if ( ! in_array( $h, $out, true ) ) {
			$out[] = $h;
		}
	}
	return $out;
}

/**
 * §3.3 -- whether $ip falls inside a private/reserved range. Unparseable
 * input is rejected (returns true) rather than let through.
 *
 * @param string $ip Candidate IP address (v4 or v6, no brackets/port).
 * @return bool
 */
function neccospeak_http_is_private_ip( $ip ) {
	$bin = @inet_pton( (string) $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- inet_pton() warns on unparseable input; we want the false return, not the warning.
	if ( false === $bin ) {
		return true;
	}
	$len = strlen( $bin );
	if ( 4 === $len ) {
		return neccospeak_http_ipv4_private_bin( $bin );
	}
	if ( 16 === $len ) {
		return neccospeak_http_ipv6_private_bin( $bin );
	}
	return true;
}

/**
 * §3.4 -- resolves $host to its A + AAAA IP addresses.
 *
 * @param string $host Host name (never an IP literal -- callers only use
 *                      this for names that failed the IP-literal checks).
 * @return string[] Resolved IPs, or an empty array on failure.
 */
function neccospeak_http_resolve_ips( $host ) {
	// Test-only injection point. Production registers nothing here.
	$injected = apply_filters( 'neccospeak_http_resolved_ips', null, $host );
	if ( is_array( $injected ) ) {
		return array_values( array_filter( array_map( 'strval', $injected ) ) );
	}

	$ips = array();
	if ( function_exists( 'dns_get_record' ) ) {
		$records = @dns_get_record( $host, DNS_A | DNS_AAAA ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.dns_get_record_dns_get_record -- native lookup, failure is expected/handled below.
		if ( is_array( $records ) ) {
			foreach ( $records as $r ) {
				if ( ! empty( $r['ip'] ) ) {
					$ips[] = (string) $r['ip'];
				}
				if ( ! empty( $r['ipv6'] ) ) {
					$ips[] = (string) $r['ipv6'];
				}
			}
		}
	}
	if ( empty( $ips ) ) {
		$v4 = gethostbyname( $host ); // Returns $host unchanged on failure.
		if ( $v4 !== $host ) {
			$ips[] = $v4;
		}
	}
	return array_values( array_unique( $ips ) );
}

/**
 * §3.2 -- the 7-stage URL validator. Order is load-bearing: it is exercised
 * one stage at a time by tests/test-http.php's H-* cases.
 *
 * @param string $url  Candidate absolute URL.
 * @param array  $args Optional: 'allow_hosts' (string[]) to narrow the
 *                      allow-list further for one call. NEVER to widen it --
 *                      the intersection with neccospeak_http_allowed_hosts()
 *                      is what is actually used.
 * @return true|WP_Error
 */
function neccospeak_http_validate_url( $url, $args = array() ) {
	$args = is_array( $args ) ? $args : array();

	// V1: shape.
	if ( ! is_string( $url ) || '' === $url || strlen( $url ) > NECCOSPEAK_HTTP_MAX_URL_BYTES
		|| preg_match( '/[\x00-\x1F\x7F\s]/', $url )
	) {
		return neccospeak_http_error( 'neccospeak_http_bad_url', __( 'The URL is malformed or too long.', 'neccospeak' ) );
	}
	$parts = wp_parse_url( $url );
	if ( ! is_array( $parts ) || empty( $parts['host'] ) || ! is_string( $parts['host'] ) ) {
		return neccospeak_http_error( 'neccospeak_http_bad_url', __( 'The URL is malformed or too long.', 'neccospeak' ) );
	}

	$scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';

	// Host normalization (feeds V2's same-site exception, V4's port default,
	// V5's allow-list match, and V7's same-host exception -- computing it
	// once up front is a pure computation, not a check of its own; an IDN
	// failure is only surfaced as an error at V5, per §3.2's table).
	$host_norm  = strtolower( rtrim( $parts['host'], '.' ) );
	$idn_failed = false;
	if ( preg_match( '/[^\x00-\x7F]/', $host_norm ) ) {
		if ( function_exists( 'idn_to_ascii' ) ) {
			$idn = idn_to_ascii( $host_norm, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 );
			if ( is_string( $idn ) && '' !== $idn ) {
				$host_norm = strtolower( $idn );
			} else {
				$idn_failed = true;
			}
		} else {
			$idn_failed = true;
		}
	}

	$site_host   = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
	$site_scheme = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_SCHEME ) );
	$same_site   = ( ! $idn_failed && '' !== $site_host && $host_norm === $site_host );
	$same_site_http_exception = ( $same_site && 'http' === $site_scheme );

	// V2: scheme.
	if ( 'https' !== $scheme && ! ( 'http' === $scheme && $same_site_http_exception ) ) {
		return neccospeak_http_error( 'neccospeak_http_scheme', __( 'Only https:// URLs are allowed.', 'neccospeak' ) );
	}

	// V3: no embedded credentials.
	if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
		return neccospeak_http_error( 'neccospeak_http_userinfo', __( 'URLs with embedded credentials are not allowed.', 'neccospeak' ) );
	}

	// V4: default port only.
	if ( isset( $parts['port'] ) ) {
		$expected_port = ( 'https' === $scheme ) ? 443 : 80;
		if ( (int) $parts['port'] !== $expected_port ) {
			return neccospeak_http_error( 'neccospeak_http_port', __( 'Only the default port is allowed.', 'neccospeak' ) );
		}
	}

	// V5: host allow-list (narrowed, never widened, by $args['allow_hosts']).
	$hosts = neccospeak_http_allowed_hosts();
	if ( ! empty( $args['allow_hosts'] ) && is_array( $args['allow_hosts'] ) ) {
		$narrow = array();
		foreach ( $args['allow_hosts'] as $h ) {
			if ( is_string( $h ) ) {
				$narrow[] = strtolower( rtrim( trim( $h ), '.' ) );
			}
		}
		$hosts = array_values( array_intersect( $hosts, $narrow ) );
	}
	if ( $idn_failed || ! in_array( $host_norm, $hosts, true ) ) {
		return neccospeak_http_error( 'neccospeak_http_host_not_allowed', __( 'This host is not on the allow-list.', 'neccospeak' ) );
	}

	// V6: name resolution / IP-literal parsing.
	if ( 0 === strpos( $host_norm, '[' ) ) {
		if ( ! preg_match( '/^\[(.+)\]$/', $host_norm, $m ) || false === filter_var( $m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			return neccospeak_http_error( 'neccospeak_http_dns_failed', __( 'The host name could not be resolved.', 'neccospeak' ) );
		}
		$ips = array( $m[1] );
	} elseif ( preg_match( '/^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/', $host_norm ) ) {
		$ips = array( $host_norm );
	} elseif ( false !== strpos( $host_norm, ':' ) || neccospeak_http_looks_like_alt_ipv4( $host_norm ) ) {
		// A bare (unbracketed) colon host, or a decimal/octal/hex/partial
		// IPv4 shorthand: reject outright rather than resolve-then-judge
		// (§3.3's closing note -- some resolvers accept these as literal
		// IPs, which the strict regex above is deliberately built to not).
		return neccospeak_http_error( 'neccospeak_http_dns_failed', __( 'The host name could not be resolved.', 'neccospeak' ) );
	} else {
		$ips = neccospeak_http_resolve_ips( $host_norm );
		if ( empty( $ips ) ) {
			return neccospeak_http_error( 'neccospeak_http_dns_failed', __( 'The host name could not be resolved.', 'neccospeak' ) );
		}
	}

	// V7: every resolved IP must be public, unless the same-host exception
	// applies (§3.2's "★ same-host exception" -- Docker/reverse-proxy/
	// intranet installs routinely have home_url() resolve to a private IP;
	// the allow-list (V5) and userinfo/port checks (V3/V4) still fully
	// apply, so this only widens what "self" may resolve to, never who else
	// may be reached).
	if ( ! $same_site ) {
		foreach ( $ips as $ip ) {
			if ( neccospeak_http_is_private_ip( $ip ) ) {
				return neccospeak_http_error( 'neccospeak_http_private_ip', __( 'The host resolves to a private or reserved IP address.', 'neccospeak' ) );
			}
		}
	}

	return true;
}

/**
 * §3.5 -- shared redirect-following core for the three safe_remote_*()
 * wrappers. WP's HTTP API is told never to follow redirects itself
 * ('redirection' => 0); this loop re-validates the Location header against
 * the full §3.2 pipeline on every hop before following it.
 *
 * @param string $url  Absolute URL.
 * @param array  $args 'method' (default GET), 'body', 'headers', 'timeout',
 *                      'allow_hosts' (see neccospeak_http_validate_url()).
 * @return array|WP_Error wp_remote_get()-compatible response, or WP_Error.
 */
function neccospeak_safe_remote_request( $url, $args = array() ) {
	$args    = is_array( $args ) ? $args : array();
	$method  = isset( $args['method'] ) ? strtoupper( (string) $args['method'] ) : 'GET';
	$body    = array_key_exists( 'body', $args ) ? $args['body'] : null;
	$timeout = isset( $args['timeout'] ) ? (int) $args['timeout'] : NECCOSPEAK_HTTP_TIMEOUT_DEFAULT;
	$headers = isset( $args['headers'] ) && is_array( $args['headers'] ) ? $args['headers'] : array();

	$current = $url;

	for ( $hop = 0; $hop <= NECCOSPEAK_HTTP_MAX_REDIRECTS; $hop++ ) {

		$valid = neccospeak_http_validate_url( $current, $args ); // Re-validated on every hop.
		if ( is_wp_error( $valid ) ) {
			return $valid;
		}

		$res = wp_remote_request(
			$current,
			array(
				'method'              => $method,
				'timeout'             => $timeout,
				'redirection'         => 0, // Internal redirect-following killed; we do it ourselves.
				'reject_unsafe_urls'  => true, // Core's own defense too (belt and braces).
				'httpversion'         => '1.1',
				'user-agent'          => 'NeccoSpeak/' . ( defined( 'NECCOSPEAK_VERSION' ) ? NECCOSPEAK_VERSION : '0' ) . '; ' . home_url( '/' ),
				'headers'             => $headers,
				'body'                => $body,
				'limit_response_size' => NECCOSPEAK_HTTP_MAX_BODY_BYTES,
			)
		);
		if ( is_wp_error( $res ) ) {
			return $res;
		}

		$code = (int) wp_remote_retrieve_response_code( $res );
		if ( ! in_array( $code, array( 301, 302, 303, 307, 308 ), true ) ) {
			return $res; // Terminal.
		}

		$loc = trim( (string) wp_remote_retrieve_header( $res, 'location' ) );
		if ( '' === $loc ) {
			return neccospeak_http_error( 'neccospeak_http_redirect_no_location', __( 'The redirect response had no Location header.', 'neccospeak' ) );
		}

		// Absolutize a relative Location using WP core's own helper (never
		// hand-rolled).
		$current = ( 0 === strpos( $loc, 'http://' ) || 0 === strpos( $loc, 'https://' ) )
			? $loc
			: WP_Http::make_absolute_url( $loc, $current );

		// Method transition per RFC 9110 / browser convention: 303 always
		// becomes GET; 301/302 become GET only when the original method was
		// POST. 307/308 keep method and body.
		if ( 303 === $code || ( in_array( $code, array( 301, 302 ), true ) && 'POST' === $method ) ) {
			$method = 'GET';
			$body   = null;
		}
	}

	return neccospeak_http_error( 'neccospeak_http_too_many_redirects', __( 'Too many redirects.', 'neccospeak' ) );
}

/**
 * §3.5 -- GET wrapper.
 *
 * @param string $url  Absolute URL.
 * @param array  $args See neccospeak_safe_remote_request().
 * @return array|WP_Error
 */
function neccospeak_safe_remote_get( $url, $args = array() ) {
	$args           = is_array( $args ) ? $args : array();
	$args['method'] = 'GET';
	return neccospeak_safe_remote_request( $url, $args );
}

/**
 * §3.5 -- POST wrapper.
 *
 * @param string $url  Absolute URL.
 * @param array  $args See neccospeak_safe_remote_request().
 * @return array|WP_Error
 */
function neccospeak_safe_remote_post( $url, $args = array() ) {
	$args           = is_array( $args ) ? $args : array();
	$args['method'] = 'POST';
	return neccospeak_safe_remote_request( $url, $args );
}

/**
 * §2.1 H8 -- Diagnostics summary.
 *
 * @return array{allowed_hosts:string[], dns:string, idn:bool, last_error:string, same_host_exempt:bool}
 */
function neccospeak_http_diag() {
	$site_host = strtolower( (string) wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );

	$same_host_exempt = false;
	if ( '' !== $site_host ) {
		foreach ( neccospeak_http_resolve_ips( $site_host ) as $ip ) {
			if ( neccospeak_http_is_private_ip( $ip ) ) {
				$same_host_exempt = true;
				break;
			}
		}
	}

	$last_error = '';
	$stored     = get_option( NECCOSPEAK_HTTP_LAST_ERROR_OPTION, array() );
	if ( is_array( $stored ) && ! empty( $stored['code'] ) ) {
		$last_error = (string) $stored['code'];
	}

	return array(
		'allowed_hosts'    => neccospeak_http_allowed_hosts(),
		'dns'              => function_exists( 'dns_get_record' ) ? 'dns_get_record' : 'gethostbyname',
		'idn'              => function_exists( 'idn_to_ascii' ),
		'last_error'       => $last_error,
		'same_host_exempt' => $same_host_exempt,
	);
}
