<?php
/**
 * Loopback fetcher: retrieves the site's own public pages the way a search
 * engine would, with an honest self-check.
 *
 * Design rule (from the build contract): a broken loopback must NEVER produce
 * a false "all clear". Every fetch failure is surfaced as monitoring-impaired
 * state, never swallowed as "no changes detected".
 *
 * The only URLs this class ever requests are the site's own (home_url-based
 * monitored pages, robots.txt, the sitemap). It performs no other network
 * activity of any kind. That is enforced on every request this class makes, not
 * only on the URL it is handed: redirects are never followed by the HTTP layer
 * ( 'redirection' => 0 ). They are resolved here, one hop at a time, and each
 * destination is re-checked against home_url BEFORE it is requested. A monitored
 * URL that starts redirecting off-site is RECORDED (that change is the point of
 * the plugin) but never followed.
 *
 * @package SEO_Flight_Recorder
 */

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

/**
 * Class SFRec_Fetcher
 */
class SFRec_Fetcher {

	/**
	 * How many redirect hops we resolve when a monitored URL redirects. Each
	 * destination is host-checked before it is requested, so this is a loop
	 * bound, not a trust budget.
	 */
	const MAX_HOPS = 3;

	/**
	 * Is a URL on this site's own host? A URL with no host of its own is not
	 * something this class will request.
	 *
	 * @param string $url URL.
	 * @return bool
	 */
	private static function is_same_host( $url ) {
		$home = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $host || ! $home ) {
			return false;
		}
		return strtolower( (string) $host ) === strtolower( (string) $home );
	}

	/**
	 * Fetch a URL and return a normalized result.
	 *
	 * @param string $url  URL (must be same-host as home_url; enforced).
	 * @param array  $args Optional: ['cache_bypass' => bool].
	 * @return array|WP_Error {body, headers (lowercased), status, redirect_to}.
	 */
	public static function fetch( $url, $args = array() ) {
		if ( ! self::is_same_host( $url ) ) {
			return new WP_Error( 'sfrec_foreign_host', __( 'SEO Flight Recorder only fetches this site\'s own URLs.', 'buildsbyluke-seo-flight-recorder' ) );
		}

		/**
		 * Test seam: short-circuit the network fetch. Returning an array shaped
		 * like ['body' => ..., 'headers' => [], 'status' => 200, 'redirect_to' => '']
		 * makes the whole pipeline run against fixtures.
		 *
		 * @param null|array|WP_Error $pre  Null to fetch normally.
		 * @param string              $url  The URL being fetched.
		 * @param array               $args Fetch args.
		 */
		$pre = apply_filters( 'sfrec_pre_fetch', null, $url, $args );
		if ( null !== $pre ) {
			return $pre;
		}

		$request_url = $url;
		if ( ! empty( $args['cache_bypass'] ) ) {
			// A unique query string defeats most page caches (their key includes
			// the query). Documented limitation: sites that echo the query string
			// into canonicals will see a canonical INFO diff while bypass is on.
			$request_url = add_query_arg( 'sfrec-cb', (string) time(), $url );
		}

		$request_args = array(
			'timeout'     => 15,
			'redirection' => 0, // Never let the HTTP layer pick the next URL; see follow().
			'user-agent'  => 'SEOFlightRecorder/' . SFREC_VERSION . ' (WordPress)',
			'headers'     => ! empty( $args['cache_bypass'] ) ? array( 'Cache-Control' => 'no-cache' ) : array(),
		);

		$response = wp_safe_remote_get( $request_url, $request_args );

		// Same-host loopback with an SSL failure: retry once without peer
		// verification, the same concession WP core's own loopback checks make
		// for self-signed local certificates. Applies ONLY to our own host.
		if ( is_wp_error( $response ) && false !== stripos( implode( ' ', $response->get_error_messages() ), 'ssl' ) ) {
			$request_args['sslverify'] = false;
			$response                  = wp_safe_remote_get( $request_url, $request_args );
		}

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

		$status      = (int) wp_remote_retrieve_response_code( $response );
		$redirect_to = '';

		if ( $status >= 300 && $status < 400 ) {
			$redirect_to = (string) wp_remote_retrieve_header( $response, 'location' );
			// Follow to the final destination for content (bounded), but keep
			// the first hop recorded — a monitored URL BECOMING a redirect is
			// itself a finding. The chain is followed ONLY while it stays on this
			// site: every destination is re-checked against home_url before it is
			// requested. A redirect that leaves this site is recorded in
			// redirect_to and NOT followed, so this plugin's requests never reach
			// another server; body and headers then stay those of the redirect
			// response itself.
			$follow = self::follow( $request_url, $redirect_to, $request_args );
			if ( ! is_wp_error( $follow ) ) {
				$response = $follow;
			}
		}

		$raw_headers = wp_remote_retrieve_headers( $response );
		$headers     = array();
		if ( is_object( $raw_headers ) && method_exists( $raw_headers, 'getAll' ) ) {
			$raw_headers = $raw_headers->getAll();
		}
		if ( is_array( $raw_headers ) ) {
			foreach ( $raw_headers as $header_key => $header_value ) {
				$headers[ strtolower( (string) $header_key ) ] = is_array( $header_value ) ? implode( ', ', $header_value ) : (string) $header_value;
			}
		}

		return array(
			'body'        => wp_remote_retrieve_body( $response ),
			'headers'     => $headers,
			'status'      => $status,
			'redirect_to' => $redirect_to,
		);
	}

	/**
	 * Walk a redirect chain for its final content, but only while it stays on
	 * this site. Every destination is resolved here and re-checked against
	 * home_url BEFORE it is requested; the HTTP layer never follows a redirect
	 * itself ( 'redirection' => 0 in $args ). A chain that leaves this site
	 * returns an error, and the caller keeps the redirect response it already
	 * has — nothing off-site is ever requested.
	 *
	 * @param string $from_url URL that returned the first redirect.
	 * @param string $location Its Location header.
	 * @param array  $args     Request args (already carrying 'redirection' => 0).
	 * @return array|WP_Error wp_remote_* response, or an error.
	 */
	private static function follow( $from_url, $location, $args ) {
		$current  = $from_url;
		$next_raw = trim( (string) $location );

		for ( $hop = 0; $hop < self::MAX_HOPS; $hop++ ) {
			$next = self::resolve_redirect( $next_raw, $current );
			if ( '' === $next ) {
				return new WP_Error(
					'sfrec_bad_redirect',
					sprintf(
						/* translators: %s: URL on this site. */
						__( '%s redirects somewhere SEO Flight Recorder cannot read.', 'buildsbyluke-seo-flight-recorder' ),
						$current
					)
				);
			}

			if ( ! self::is_same_host( $next ) ) {
				return new WP_Error(
					'sfrec_offsite_redirect',
					sprintf(
						/* translators: 1: URL on this site, 2: the other host it redirects to. */
						__( '%1$s redirects to another host (%2$s). SEO Flight Recorder records the redirect but does not follow it off this site.', 'buildsbyluke-seo-flight-recorder' ),
						$current,
						(string) wp_parse_url( $next, PHP_URL_HOST )
					)
				);
			}

			$response = wp_safe_remote_get( $next, $args );
			if ( is_wp_error( $response ) ) {
				return $response;
			}

			$status = (int) wp_remote_retrieve_response_code( $response );
			if ( $status < 300 || $status >= 400 ) {
				return $response;
			}

			$next_raw = trim( (string) wp_remote_retrieve_header( $response, 'location' ) );
			if ( '' === $next_raw ) {
				return $response; // A redirect with nowhere to go — report it as it came.
			}

			$current = $next;
		}

		return new WP_Error(
			'sfrec_too_many_redirects',
			sprintf(
				/* translators: 1: URL, 2: number of redirects. */
				__( '%1$s redirected more than %2$d times on this site.', 'buildsbyluke-seo-flight-recorder' ),
				$from_url,
				(int) self::MAX_HOPS
			)
		);
	}

	/**
	 * Resolve a Location header against the URL that produced it. Returns '' for
	 * anything that is not an http(s) destination we can evaluate.
	 *
	 * @param string $location Location header value.
	 * @param string $base     URL that returned it.
	 * @return string Absolute http(s) URL, or ''.
	 */
	private static function resolve_redirect( $location, $base ) {
		$location = trim( $location );
		if ( '' === $location ) {
			return '';
		}

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

		$parts = wp_parse_url( $base );
		if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return '';
		}

		// Protocol-relative ( //host/path ) inherits the current scheme.
		if ( 0 === strpos( $location, '//' ) ) {
			return $parts['scheme'] . ':' . $location;
		}

		// Any other scheme (mailto:, javascript:, data:) is not a page we fetch.
		if ( preg_match( '#^[a-z][a-z0-9+.\-]*:#i', $location ) ) {
			return '';
		}

		$root = $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' );

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

		$path = ( isset( $parts['path'] ) && '' !== $parts['path'] ) ? $parts['path'] : '/';
		$dir  = ( '/' === substr( $path, -1 ) ) ? $path : rtrim( str_replace( '\\', '/', dirname( $path ) ), '/' ) . '/';

		return $root . $dir . $location;
	}

	/**
	 * Loopback self-check: can this server fetch its own front page at all?
	 *
	 * Stores the outcome in sfrec_state and returns it. Runs before every
	 * scheduled check; a failure flips the plugin into the visible
	 * "monitoring impaired" state (dashboard, Site Health, alert email).
	 *
	 * @return bool
	 */
	public static function self_check() {
		$result = self::fetch( home_url( '/' ) );

		$state = get_option( 'sfrec_state', array() );
		if ( is_wp_error( $result ) ) {
			$state['loopback_ok']    = false;
			$state['loopback_error'] = $result->get_error_message();
		} elseif ( $result['status'] >= 500 || '' === (string) $result['body'] ) {
			$state['loopback_ok']    = false;
			$state['loopback_error'] = sprintf( 'HTTP %d with %s body', (int) $result['status'], '' === (string) $result['body'] ? 'empty' : 'non-empty' );
		} else {
			$state['loopback_ok']    = true;
			$state['loopback_error'] = '';
		}
		update_option( 'sfrec_state', $state, false );

		return (bool) $state['loopback_ok'];
	}
}
