<?php
/**
 * Loopback fetcher: requests the site's OWN URLs to establish, as a served
 * fact, whether an upload is reachable from the outside.
 *
 * Design rule: a broken loopback must NEVER produce a false "protected". A
 * fetch failure is surfaced as impaired state, never swallowed as "blocked".
 *
 * OUTBOUND SURFACE. The only URLs this class requests are the site's own (same
 * host as home_url) — the plugin's outbound surface is the site's own server,
 * full stop. 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 file URL
 * that redirects to another host (a CDN, object storage) is refused and reported
 * as unknown — never followed, and never counted as either exposed or protected.
 *
 * @package Upload_Vault
 */

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

/**
 * Class Uvgf_Fetcher
 */
class Uvgf_Fetcher {

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

	/**
	 * Is a URL on this site's own host?
	 *
	 * @param string $url URL.
	 * @return bool
	 */
	public 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 ) {
			return true; // Relative URL — same host.
		}
		return strtolower( (string) $host ) === strtolower( (string) $home );
	}

	/**
	 * GET a URL over loopback.
	 *
	 * @param string $url URL (same host as home_url; enforced).
	 * @return array|WP_Error {body, status}.
	 */
	public static function fetch( $url ) {
		if ( ! self::is_same_host( $url ) ) {
			return new WP_Error( 'uvgf_foreign_host', __( 'Upload Vault only fetches this site\'s own URLs.', 'buildsbyluke-upload-vault-for-gravity-forms' ) );
		}

		/**
		 * Test seam: return an array shaped like the result to run against fixtures.
		 *
		 * @param null|array|WP_Error $pre Null to fetch normally.
		 * @param string              $url The URL.
		 */
		$pre = apply_filters( 'uvgf_pre_fetch', null, $url );
		if ( null !== $pre ) {
			return $pre;
		}

		$response = self::request( $url );

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

		return array(
			'body'   => (string) wp_remote_retrieve_body( $response ),
			'status' => (int) wp_remote_retrieve_response_code( $response ),
		);
	}

	/**
	 * Perform a request, resolving any redirects here so that every hop's host is
	 * re-checked against home_url BEFORE that hop is requested. The HTTP layer is
	 * never allowed to choose a URL for us.
	 *
	 * @param string $url Absolute URL on this site's host.
	 * @return array|WP_Error wp_remote_* response, or an error.
	 */
	private static function request( $url ) {
		$current = $url;

		// One initial request, plus up to MAX_HOPS follow-ups.
		for ( $hop = 0; $hop <= self::MAX_HOPS; $hop++ ) {
			if ( ! self::is_same_host( $current ) ) {
				return new WP_Error( 'uvgf_foreign_host', __( 'Upload Vault only fetches this site\'s own URLs.', 'buildsbyluke-upload-vault-for-gravity-forms' ) );
			}

			$response = self::send( $current );
			if ( is_wp_error( $response ) ) {
				return $response;
			}

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

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

			$next = self::resolve_redirect( $location, $current );
			if ( '' === $next ) {
				return new WP_Error(
					'uvgf_bad_redirect',
					sprintf(
						/* translators: %s: URL on this site. */
						__( '%s redirects somewhere Upload Vault cannot read.', 'buildsbyluke-upload-vault-for-gravity-forms' ),
						$current
					)
				);
			}

			if ( ! self::is_same_host( $next ) ) {
				return new WP_Error(
					'uvgf_offsite_redirect',
					sprintf(
						/* translators: 1: URL on this site, 2: the other host it redirects to. */
						__( '%1$s redirects to another host (%2$s). Upload Vault does not follow redirects off this site, so it stopped here and cannot say whether that file is reachable.', 'buildsbyluke-upload-vault-for-gravity-forms' ),
						$current,
						(string) wp_parse_url( $next, PHP_URL_HOST )
					)
				);
			}

			$current = $next;
		}

		return new WP_Error(
			'uvgf_too_many_redirects',
			sprintf(
				/* translators: 1: URL, 2: number of redirects. */
				__( '%1$s redirected more than %2$d times on this site.', 'buildsbyluke-upload-vault-for-gravity-forms' ),
				$url,
				(int) self::MAX_HOPS
			)
		);
	}

	/**
	 * One request, with redirect following switched off. Uses the safe wrapper so
	 * core's own URL validation runs alongside ours.
	 *
	 * @param string $url Absolute URL on this site's host.
	 * @return array|WP_Error
	 */
	private static function send( $url ) {
		$args = array(
			'timeout'     => 15,
			'redirection' => 0, // Never let the HTTP layer pick the next URL; see request().
			'user-agent'  => 'UploadVault/' . UVGF_VERSION . ' (WordPress)',
		);

		$response = wp_safe_remote_get( $url, $args );

		// A same-host loopback that fails on SSL: retry once without peer
		// verification — the same concession core's own loopback checks make for
		// self-signed local certificates. Still this host, still no redirects.
		if ( is_wp_error( $response ) && false !== stripos( implode( ' ', $response->get_error_messages() ), 'ssl' ) ) {
			$args['sslverify'] = false;
			$response          = wp_safe_remote_get( $url, $args );
		}

		return $response;
	}

	/**
	 * 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 URL 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;
	}

	/**
	 * Classify what a direct request to an upload URL returns.
	 *
	 * @param string $url File URL (same host; enforced by fetch()).
	 * @return array {result: exposed|blocked|missing|error, status:int, detail:string}
	 */
	public static function probe_file( $url ) {
		$res = self::fetch( $url );
		if ( is_wp_error( $res ) ) {
			return array(
				'result' => 'error',
				'status' => 0,
				'detail' => $res->get_error_message(),
			);
		}
		$status = (int) $res['status'];
		if ( 200 === $status && '' !== (string) $res['body'] ) {
			return array(
				'result' => 'exposed',
				'status' => $status,
				'detail' => '',
			);
		}
		if ( in_array( $status, array( 401, 403 ), true ) ) {
			return array(
				'result' => 'blocked',
				'status' => $status,
				'detail' => '',
			);
		}
		if ( 404 === $status || 410 === $status ) {
			return array(
				'result' => 'missing',
				'status' => $status,
				'detail' => '',
			);
		}
		return array(
			'result' => 'error',
			'status' => $status,
			'detail' => 200 === $status ? 'empty body' : 'unexpected status',
		);
	}

	/**
	 * Does a directory URL expose a browsable index listing?
	 *
	 * @param string $url Directory URL (trailing slash added).
	 * @return array {listing:bool, status:int}
	 */
	public static function probe_listing( $url ) {
		$res = self::fetch( trailingslashit( $url ) );
		if ( is_wp_error( $res ) ) {
			return array(
				'listing' => false,
				'status'  => 0,
			);
		}
		$body    = (string) $res['body'];
		$listing = 200 === (int) $res['status'] && (
			false !== stripos( $body, '<title>Index of' ) ||
			false !== stripos( $body, 'Parent Directory' ) ||
			( false !== stripos( $body, 'Directory listing for' ) )
		);
		return array(
			'listing' => $listing,
			'status'  => (int) $res['status'],
		);
	}

	/**
	 * Loopback self-check on the front page.
	 *
	 * @return bool
	 */
	public static function self_check() {
		$result = self::fetch( home_url( '/' ) );
		$state  = get_option( 'uvgf_state', array() );

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

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