<?php
/**
 * Checks a single URL over HTTP and classifies the result. Ported from
 * LinkGuard's battle-tested checking engine; redirects are followed
 * manually so hop counts and the final URL can be measured.
 *
 * @package RedirectZen
 */

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

class RDZ_Http_Checker {

	const MAX_REDIRECTS = 8;

	/**
	 * HTTP status codes where a HEAD request should be retried as GET,
	 * because many servers reject HEAD while serving GET fine.
	 *
	 * @var int[]
	 */
	private static $head_retry_codes = array( 400, 401, 403, 404, 405, 406, 410, 500, 501, 502, 503 );

	/**
	 * Checks a URL, following redirects manually.
	 *
	 * @param string $url URL to check.
	 * @return array {
	 *     @type int      $http_status Final HTTP status (0 on network error).
	 *     @type int      $hops        Number of redirect hops followed.
	 *     @type string   $final_url   URL after redirects (empty if none).
	 *     @type bool     $timed_out   Whether the request timed out.
	 *     @type string[] $trace       Every URL visited, starting with $url.
	 * }
	 */
	public static function check( $url ) {
		$current   = $url;
		$hops      = 0;
		$code      = 0;
		$timed_out = false;
		$trace     = array( $url );

		for ( $hop = 0; $hop <= self::MAX_REDIRECTS; $hop++ ) {
			$response = self::request( $current, 'HEAD' );

			if ( is_wp_error( $response ) ) {
				$timed_out = self::is_timeout( $response );
				$code      = 0;
				break;
			}

			$code = (int) wp_remote_retrieve_response_code( $response );

			// Some servers reject HEAD; retry the same hop with GET.
			if ( in_array( $code, self::$head_retry_codes, true ) ) {
				$response = self::request( $current, 'GET' );
				if ( is_wp_error( $response ) ) {
					$timed_out = self::is_timeout( $response );
					$code      = 0;
					break;
				}
				$code = (int) wp_remote_retrieve_response_code( $response );
			}

			if ( $code < 300 || $code >= 400 ) {
				break;
			}

			$location = wp_remote_retrieve_header( $response, 'location' );
			if ( is_array( $location ) ) {
				$location = end( $location );
			}
			if ( ! $location ) {
				break;
			}

			$next = self::resolve_url( $location, $current );
			if ( null === $next || $next === $current ) {
				break;
			}

			$hops++;
			$current = $next;
			$trace[] = $next;

			if ( $hops > self::MAX_REDIRECTS ) {
				break;
			}
		}

		return array(
			'http_status' => $code,
			'hops'        => $hops,
			'final_url'   => ( $current !== $url ) ? $current : '',
			'timed_out'   => $timed_out,
			'trace'       => $trace,
		);
	}

	/**
	 * Resolves a Location header value against the URL it came from.
	 *
	 * @param string $location Location header (absolute, protocol-relative, root-relative or relative).
	 * @param string $base     URL the response came from.
	 * @return string|null Absolute URL, or null when unresolvable.
	 */
	public static function resolve_url( $location, $base ) {
		$location = trim( (string) $location );
		if ( '' === $location ) {
			return null;
		}

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

		$parts = wp_parse_url( $base );
		if ( empty( $parts['host'] ) ) {
			return null;
		}
		$scheme = isset( $parts['scheme'] ) ? $parts['scheme'] : 'https';
		$origin = $scheme . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' );

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

		if ( '/' === $location[0] ) {
			return $origin . $location;
		}

		// Relative path: resolve against the base path's directory.
		$base_path = isset( $parts['path'] ) ? $parts['path'] : '/';
		$dir       = substr( $base_path, 0, (int) strrpos( $base_path, '/' ) + 1 );

		return $origin . $dir . $location;
	}

	/**
	 * Performs a single, non-following HTTP request.
	 *
	 * @param string $url    URL.
	 * @param string $method HEAD or GET.
	 * @return array|WP_Error
	 */
	private static function request( $url, $method ) {
		$args = array(
			'method'      => $method,
			'timeout'     => (int) RDZ_Settings::get( 'request_timeout' ),
			'redirection' => 0,
			'sslverify'   => true,
			'user-agent'  => self::user_agent(),
			'headers'     => array(
				'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
			),
		);

		if ( 'GET' === $method ) {
			// Never download more than 100 KB; the status line is all we need.
			$args['limit_response_size'] = 102400;
		}

		/**
		 * Filters the HTTP request arguments used for redirect verification.
		 *
		 * @param array  $args Request arguments.
		 * @param string $url  URL being checked.
		 */
		$args = apply_filters( 'rdz_check_request_args', $args, $url );

		return wp_remote_request( $url, $args );
	}

	/**
	 * Whether a WP_Error represents a timeout (vs. DNS failure etc.).
	 *
	 * @param WP_Error $error Error from the HTTP API.
	 * @return bool
	 */
	private static function is_timeout( $error ) {
		$message = strtolower( $error->get_error_message() );

		return false !== strpos( $message, 'timed out' ) || false !== strpos( $message, 'timeout' );
	}

	/**
	 * User agent string for outgoing checks.
	 *
	 * @return string
	 */
	private static function user_agent() {
		return 'Mozilla/5.0 (compatible; RedirectZen/' . RDZ_VERSION . '; +' . home_url( '/' ) . ')';
	}
}
