<?php
/**
 * Redirect verification: the RedirectZen wedge. Each rule's source URL is
 * requested against the live site and followed end-to-end, proving the
 * redirect actually fires and lands on a working page. No incumbent does
 * this — "redirects that silently fail" is the #1 complaint against them.
 *
 * Verify statuses on a rule:
 *   pending — never verified (regex rules stay here; they have no single URL)
 *   queued  — waiting for the background verifier
 *   ok      — fires and lands on a 2xx
 *   warn    — works but is fragile (redirect chain, or lands somewhere
 *             other than the configured target)
 *   fail    — does not fire, loops, or lands on an error
 *
 * @package RedirectZen
 */

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

class RDZ_Verifier {

	/**
	 * Verifies one rule and stores the result.
	 *
	 * @param array $rule Rule row.
	 * @return array Updated rule row.
	 */
	public static function verify_rule( $rule ) {
		global $wpdb;

		if ( (int) $rule['is_regex'] ) {
			// Regex rules have no single source URL to request.
			return $rule;
		}

		$source_url = home_url( $rule['source'] );
		$result     = RDZ_Http_Checker::check( $source_url );

		$status = self::classify( $result, $rule );

		$effective_hops = isset( $result['trace'] ) ? self::count_effective_hops( $result['trace'] ) : (int) $result['hops'];

		$wpdb->update(
			RDZ_Redirects::table(),
			array(
				'verify_status' => $status,
				'verify_http'   => (int) $result['http_status'],
				'verify_hops'   => min( 255, $effective_hops ),
				'verify_final'  => (string) $result['final_url'],
				'last_verified' => current_time( 'mysql', true ),
			),
			array( 'id' => (int) $rule['id'] ),
			array( '%s', '%d', '%d', '%s', '%s' ),
			array( '%d' )
		);

		return RDZ_Redirects::get( (int) $rule['id'] );
	}

	/**
	 * Classifies a check result for a rule. Pure logic, unit tested.
	 *
	 * Hop counting uses EFFECTIVE hops: pure canonicalization redirects
	 * (http→https, www→apex, trailing slash) that many sites apply to every
	 * URL don't make a redirect fragile, so they don't count against it.
	 *
	 * @param array $result RDZ_Http_Checker::check() result.
	 * @param array $rule   Rule row (target, redirect_type).
	 * @return string ok|warn|fail
	 */
	public static function classify( $result, $rule ) {
		$code = (int) $result['http_status'];
		$hops = isset( $result['trace'] ) ? self::count_effective_hops( $result['trace'] ) : (int) $result['hops'];

		if ( 0 === $code || $code >= 400 ) {
			// Never fired, timed out, or landed on an error page.
			return 'fail';
		}

		if ( $code >= 300 ) {
			// Still 3xx after MAX_REDIRECTS hops: loop or absurd chain.
			return 'fail';
		}

		if ( 0 === $hops ) {
			// The source answered 200 directly — the redirect did not fire
			// (another plugin/page may be shadowing it).
			return 'fail';
		}

		// Landed on a 2xx. Fragility checks:
		if ( $hops >= 2 ) {
			return 'warn'; // Chains leak link equity and add latency.
		}

		$final  = (string) $result['final_url'];
		$target = (string) $rule['target'];
		if ( $final && $target && ! self::urls_equivalent( $final, $target ) ) {
			return 'warn'; // Fired, but landed somewhere unexpected.
		}

		return 'ok';
	}

	/**
	 * Counts redirect hops that actually moved to a different resource.
	 * A hop is pure canonicalization — and doesn't count — when the path
	 * and query are unchanged (trailing-slash-insensitive) and the host is
	 * the same modulo a leading "www.". Pure logic, unit tested.
	 *
	 * @param string[] $trace URLs visited, in order.
	 * @return int
	 */
	public static function count_effective_hops( $trace ) {
		$trace = array_values( (array) $trace );
		$hops  = 0;

		for ( $i = 1, $n = count( $trace ); $i < $n; $i++ ) {
			if ( ! self::is_canonicalization_hop( $trace[ $i - 1 ], $trace[ $i ] ) ) {
				$hops++;
			}
		}

		return $hops;
	}

	/**
	 * Whether a redirect from $a to $b only normalizes scheme/www/slash.
	 *
	 * @param string $a From URL.
	 * @param string $b To URL.
	 * @return bool
	 */
	public static function is_canonicalization_hop( $a, $b ) {
		$parse = static function ( $url ) {
			$parts = wp_parse_url( (string) $url );
			$host  = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '';
			$host  = preg_replace( '/^www\./', '', $host );
			$path  = isset( $parts['path'] ) ? $parts['path'] : '/';
			if ( strlen( $path ) > 1 ) {
				$path = rtrim( $path, '/' );
			}
			$query = isset( $parts['query'] ) ? $parts['query'] : '';

			return array( $host, $path, $query );
		};

		return $parse( $a ) === $parse( $b );
	}

	/**
	 * Loose URL equality for verification: scheme-insensitive, trailing
	 * slash insensitive, fragment ignored.
	 *
	 * @param string $a URL.
	 * @param string $b URL.
	 * @return bool
	 */
	public static function urls_equivalent( $a, $b ) {
		$norm = static function ( $url ) {
			$url = strtolower( trim( (string) $url ) );
			$url = preg_replace( '/#.*$/', '', $url );
			$url = preg_replace( '#^https?://#', '', $url );
			$url = preg_replace( '#^www\.#', '', $url );

			return rtrim( $url, '/' );
		};

		return $norm( $a ) === $norm( $b );
	}

	/**
	 * Processes a batch of queued rules. Called from cron.
	 *
	 * @return int Rules verified.
	 */
	public static function run_batch() {
		global $wpdb;

		$batch = (int) RDZ_Settings::get( 'verify_batch' );
		$table = RDZ_Redirects::table();

		// Queued first, then (Pro re-verification) the longest-unverified.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rules = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE is_active = 1 AND is_regex = 0 AND verify_status = 'queued' ORDER BY updated_at ASC LIMIT %d", $batch ), ARRAY_A );

		if ( empty( $rules ) ) {
			// Nothing queued → the current verification cycle is complete.
			if ( ! get_option( 'rdz_last_verify_cycle' ) || self::has_verified_rules() ) {
				update_option( 'rdz_last_verify_cycle', time(), false );
			}

			return 0;
		}

		$done = 0;
		foreach ( $rules as $rule ) {
			self::verify_rule( $rule );
			$done++;
			// Kind to the server: breathe between checks.
			usleep( 200000 );
		}

		return $done;
	}

	/**
	 * Whether any rule has a verification result at all.
	 *
	 * @return bool
	 */
	private static function has_verified_rules() {
		global $wpdb;

		$table = RDZ_Redirects::table();

		return (bool) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE last_verified IS NOT NULL" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Queues every active non-regex rule for (re-)verification.
	 *
	 * @return int Rules queued.
	 */
	public static function queue_all() {
		global $wpdb;

		$table = RDZ_Redirects::table();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->query( "UPDATE {$table} SET verify_status = 'queued' WHERE is_active = 1 AND is_regex = 0" );
	}
}
