<?php
/**
 * Checks a single URL over HTTP and classifies the result.
 *
 * @package 404Zen
 */

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

class LGBF_Checker {

	const MAX_REDIRECTS = 5;

	/**
	 * 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 so chains can be measured.
	 *
	 * @param string $url URL to check.
	 * @return array {
	 *     @type string $status         ok|broken|redirect|timeout
	 *     @type int    $http_status    Final HTTP status (0 on network error).
	 *     @type int    $redirect_count Number of redirect hops followed.
	 *     @type string $final_url      URL after redirects (empty if none).
	 * }
	 */
	public static function check( $url ) {
		$current        = $url;
		$redirect_count = 0;
		$code           = 0;
		$timed_out      = false;

		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;
			}

			// Redirect: resolve the Location header and follow.
			$location = wp_remote_retrieve_header( $response, 'location' );
			if ( is_array( $location ) ) {
				$location = end( $location );
			}
			if ( ! $location ) {
				break;
			}

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

			$redirect_count++;
			$current = $next;

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

		return array(
			'status'         => self::classify( $code, $redirect_count, $timed_out ),
			'http_status'    => $code,
			'redirect_count' => $redirect_count,
			'final_url'      => ( $current !== $url ) ? $current : '',
		);
	}

	/**
	 * Classifies a check result into a 404Zen status.
	 *
	 * @param int  $code           Final HTTP status code.
	 * @param int  $redirect_count Redirect hops.
	 * @param bool $timed_out      Whether the request timed out.
	 * @return string
	 */
	public static function classify( $code, $redirect_count, $timed_out = false ) {
		if ( 0 === $code ) {
			return $timed_out ? 'timeout' : 'broken';
		}

		if ( $code >= 400 ) {
			return 'broken';
		}

		if ( $code >= 300 ) {
			// Still a 3xx after MAX_REDIRECTS hops (redirect loop / too deep).
			return 'broken';
		}

		// Success, but a chain of 2+ redirects is worth surfacing.
		if ( $redirect_count >= 2 ) {
			return 'redirect';
		}

		return 'ok';
	}

	/**
	 * 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) LGBF_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 link checks.
		 *
		 * @param array  $args Request arguments.
		 * @param string $url  URL being checked.
		 */
		$args = apply_filters( 'lgbf_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; 404Zen/' . LGBF_VERSION . '; +' . home_url( '/' ) . ')';
	}
}
