<?php
/**
 * HTTP link checker with HEAD/GET fallback and rate limiting.
 *
 * @package BrokenLinkChecker
 * @since 1.1.0
 */

namespace BrokenLinkChecker;

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

final class HttpLinkChecker {

	private const USER_AGENT = 'Mozilla/5.0 (compatible; WP-PowerSuite-BrokenLinkChecker/1.1)';

	private const EXTERNAL_DELAY_US = 100000;

	private float $last_external_request = 0.0;

	/**
	 * @return array{working: bool, error: string, type: string, status_class: string, redirect_hops: int}
	 */
	public function check( string $url ): array {
		$url = UrlSafety::normalizeUrl( $url );

		if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
			return $this->result( false, __( 'Invalid URL format', 'wp-powersuite' ), 'Unknown', 'broken', 0 );
		}

		if ( ! UrlSafety::isAllowedUrl( $url ) ) {
			return $this->result( false, __( 'Blocked - Private or disallowed address', 'wp-powersuite' ), 'External', 'broken', 0 );
		}

		if ( UrlSafety::isInternalUrl( $url ) ) {
			return $this->checkInternal( $url );
		}

		return $this->checkExternal( $url );
	}

	/**
	 * @return array{working: bool, error: string, type: string, status_class: string, redirect_hops: int}
	 */
	private function checkInternal( string $url ): array {
		$post_id = url_to_postid( $url );
		if ( $post_id > 0 ) {
			$post = get_post( $post_id );
			if ( ! $post ) {
				return $this->result( false, '404 - ' . __( 'Post not found', 'wp-powersuite' ), 'Internal', 'broken', 0 );
			}
			if ( $post->post_status !== 'publish' ) {
				return $this->result(
					false,
					sprintf( __( 'Not published (status: %s)', 'wp-powersuite' ), $post->post_status ),
					'Internal',
					'broken',
					0
				);
			}
			return $this->result( true, '', 'Internal', 'ok', 0 );
		}

		$response = $this->request( $url, 'HEAD', 4, 2, false );
		if ( is_wp_error( $response ) ) {
			$response = $this->request( $url, 'GET', 5, 2, false );
		} elseif ( in_array( (int) wp_remote_retrieve_response_code( $response ), [ 405, 501, 403 ], true ) ) {
			$response = $this->request( $url, 'GET', 5, 2, false );
		}

		return $this->interpretResponse( $response, 'Internal', true );
	}

	/**
	 * @return array{working: bool, error: string, type: string, status_class: string, redirect_hops: int}
	 */
	private function checkExternal( string $url ): array {
		$this->throttleExternal();

		$response = $this->request( $url, 'HEAD', 5, 3, true );
		if ( is_wp_error( $response ) ) {
			$response = $this->request( $url, 'GET', 6, 3, true );
		} elseif ( in_array( (int) wp_remote_retrieve_response_code( $response ), [ 405, 501, 403 ], true ) ) {
			$response = $this->request( $url, 'GET', 6, 3, true );
		}

		return $this->interpretResponse( $response, 'External', false );
	}

	/**
	 * Performs the request with redirects disabled at the HTTP layer and
	 * manually validates + follows each hop so a redirect can't be used to
	 * reach a blocked/internal address (SSRF via redirect).
	 *
	 * @return array<string, mixed>|\WP_Error
	 */
	private function request( string $url, string $method, int $timeout, int $redirects, bool $external ): array|\WP_Error {
		if ( ! UrlSafety::isAllowedUrl( $url ) ) {
			return new \WP_Error( 'blocked_url', __( 'Blocked - Private or disallowed address', 'wp-powersuite' ) );
		}

		$args = [
			'timeout'     => $timeout,
			'redirection' => 0,
			'sslverify'   => true,
			'method'      => $method,
			'headers'     => [
				'Accept'        => '*/*',
				'Cache-Control' => 'no-cache',
			],
		];

		if ( $external ) {
			$args['user-agent'] = self::USER_AGENT;
		}
		if ( $method === 'GET' ) {
			$args['headers']['Range'] = 'bytes=0-8191';
		}

		$current_url = $url;
		$hops        = 0;

		while ( true ) {
			$response = $method === 'HEAD' ? wp_remote_head( $current_url, $args ) : wp_remote_get( $current_url, $args );

			if ( is_wp_error( $response ) ) {
				return $response;
			}

			$code = (int) wp_remote_retrieve_response_code( $response );
			if ( $code < 300 || $code >= 400 || $hops >= $redirects ) {
				$response['_redirect_hops'] = $hops;
				return $response;
			}

			$location = wp_remote_retrieve_header( $response, 'location' );
			if ( ! is_string( $location ) || $location === '' ) {
				$response['_redirect_hops'] = $hops;
				return $response;
			}

			$next_url = \WP_Http::make_absolute_url( $location, $current_url );
			if ( ! UrlSafety::isAllowedUrl( $next_url ) ) {
				return new \WP_Error( 'blocked_url', __( 'Blocked - Private or disallowed address', 'wp-powersuite' ) );
			}

			$current_url = $next_url;
			++$hops;
		}
	}

	/**
	 * @param array<string, mixed>|\WP_Error $response
	 * @return array{working: bool, error: string, type: string, status_class: string, redirect_hops: int}
	 */
	private function interpretResponse( array|\WP_Error $response, string $type, bool $check_soft_404 ): array {
		if ( is_wp_error( $response ) ) {
			$msg = $response->get_error_message();
			if ( str_contains( $msg, 'Operation timed out' ) || str_contains( $msg, 'timed out' ) ) {
				$msg = __( 'Connection timeout', 'wp-powersuite' );
			} elseif ( str_contains( $msg, 'Could not resolve host' ) ) {
				$msg = __( 'Domain not found', 'wp-powersuite' );
			}
			return $this->result( false, $msg, $type, 'broken', 0 );
		}

		$code          = (int) wp_remote_retrieve_response_code( $response );
		$redirect_hops = self::countRedirectHops( $response );

		if ( $code === 0 ) {
			return $this->result( false, __( 'No response from server', 'wp-powersuite' ), $type, 'broken', $redirect_hops );
		}

		if ( $code >= 300 && $code < 400 ) {
			return $this->result(
				true,
				sprintf(
					/* translators: 1: HTTP status code, 2: number of redirect hops */
					__( 'Redirect (HTTP %1$d, %2$d hop(s))', 'wp-powersuite' ),
					$code,
					max( 1, $redirect_hops )
				),
				$type,
				'redirect',
				$redirect_hops
			);
		}

		if ( $code >= 200 && $code < 300 ) {
			if ( $check_soft_404 && $this->looksLikeSoft404( $response ) ) {
				return $this->result( false, __( 'Soft 404 (page returns 200 but looks like not found)', 'wp-powersuite' ), $type, 'broken', $redirect_hops );
			}
			return $this->result( true, '', $type, 'ok', $redirect_hops );
		}

		if ( in_array( $code, [ 403, 429 ], true ) ) {
			return $this->result(
				false,
				sprintf(
					/* translators: %d: HTTP status code */
					__( 'Inconclusive (HTTP %d — server may block automated checks)', 'wp-powersuite' ),
					$code
				),
				$type,
				'inconclusive',
				$redirect_hops
			);
		}

		$errors = [
			400 => 'HTTP 400 - ' . __( 'Bad Request', 'wp-powersuite' ),
			401 => 'HTTP 401 - ' . __( 'Unauthorized', 'wp-powersuite' ),
			404 => 'HTTP 404 - ' . __( 'Not Found', 'wp-powersuite' ),
			410 => 'HTTP 410 - ' . __( 'Gone', 'wp-powersuite' ),
			500 => 'HTTP 500 - ' . __( 'Server Error', 'wp-powersuite' ),
			502 => 'HTTP 502 - ' . __( 'Bad Gateway', 'wp-powersuite' ),
			503 => 'HTTP 503 - ' . __( 'Service Unavailable', 'wp-powersuite' ),
		];
		$error = $errors[ $code ] ?? 'HTTP ' . $code;

		return $this->result( false, $error, $type, 'broken', $redirect_hops );
	}

	/**
	 * @param array<string, mixed> $response
	 */
	private function looksLikeSoft404( array $response ): bool {
		$body = (string) wp_remote_retrieve_body( $response );
		if ( $body === '' ) {
			return false;
		}
		$snippet = strtolower( substr( wp_strip_all_tags( $body ), 0, 4096 ) );
		$patterns = [
			'page not found',
			'404 error',
			'error 404',
			'not found',
			'nothing found',
		];
		foreach ( $patterns as $pattern ) {
			if ( str_contains( $snippet, $pattern ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param array<string, mixed> $response
	 */
	private static function countRedirectHops( array $response ): int {
		return isset( $response['_redirect_hops'] ) ? (int) $response['_redirect_hops'] : 0;
	}

	private function throttleExternal(): void {
		$now  = microtime( true );
		$wait = self::EXTERNAL_DELAY_US - (int) ( ( $now - $this->last_external_request ) * 1_000_000 );
		if ( $wait > 0 ) {
			usleep( $wait );
		}
		$this->last_external_request = microtime( true );
	}

	/**
	 * @return array{working: bool, error: string, type: string, status_class: string, redirect_hops: int}
	 */
	private function result( bool $working, string $error, string $type, string $status_class, int $redirect_hops ): array {
		return [
			'working'       => $working,
			'error'         => $error,
			'type'          => $type,
			'status_class'  => $status_class,
			'redirect_hops' => $redirect_hops,
		];
	}
}
