<?php
/**
 * The redirect engine: rule storage, matching, loop detection, and the
 * front-end request handler.
 *
 * Sources are stored in a canonical "path?query" form (no host, trailing
 * slash stripped except root) and matched by hash — a table of thousands
 * of rules costs one indexed lookup per request.
 *
 * @package RedirectZen
 */

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

class RDZ_Redirects {

	/**
	 * Max hops walked when following rule chains for loop detection.
	 */
	const MAX_CHAIN = 8;

	/**
	 * Fired at the very start of template_redirect so the redirect wins
	 * against theme/plugin output, but after the main query so imported
	 * rules behave exactly like they did in the source plugin.
	 */
	public static function init() {
		add_action( 'template_redirect', array( __CLASS__, 'maybe_redirect' ), 0 );
	}

	/**
	 * @return string Fully-prefixed redirects table name.
	 */
	public static function table() {
		global $wpdb;

		return $wpdb->prefix . 'rdz_redirects';
	}

	/**
	 * Normalizes a URL or path to the canonical "path?query" form used for
	 * matching.
	 *
	 * @param string $url Full URL or path.
	 * @return string Normalized "path?query" (no trailing slash except root), or '' if invalid.
	 */
	public static function normalize( $url ) {
		$url = trim( (string) $url );
		if ( '' === $url ) {
			return '';
		}

		if ( preg_match( '#^https?://#i', $url ) ) {
			$parts = wp_parse_url( $url );
			$path  = isset( $parts['path'] ) ? $parts['path'] : '/';
			$query = isset( $parts['query'] ) ? $parts['query'] : '';
		} else {
			if ( '/' !== $url[0] ) {
				$url = '/' . $url;
			}
			$q_pos = strpos( $url, '?' );
			if ( false !== $q_pos ) {
				$path  = substr( $url, 0, $q_pos );
				$query = substr( $url, $q_pos + 1 );
			} else {
				$path  = $url;
				$query = '';
			}
		}

		if ( '' === $path ) {
			$path = '/';
		}

		// Trailing-slash-insensitive matching (except the root path).
		if ( strlen( $path ) > 1 ) {
			$path = rtrim( $path, '/' );
		}

		return $query ? $path . '?' . $query : $path;
	}

	/**
	 * Whether a URL points at this site (host matches, or it is a bare path).
	 *
	 * @param string $url URL or path.
	 * @return bool
	 */
	public static function is_internal( $url ) {
		if ( ! preg_match( '#^https?://#i', $url ) ) {
			return true;
		}

		$host = wp_parse_url( $url, PHP_URL_HOST );
		$home = wp_parse_url( home_url(), PHP_URL_HOST );

		return is_string( $host ) && is_string( $home ) && strtolower( $host ) === strtolower( $home );
	}

	/**
	 * Redirects the current front-end request when a rule matches.
	 */
	public static function maybe_redirect() {
		if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
			return;
		}
		if ( empty( $_SERVER['REQUEST_URI'] ) ) {
			return;
		}

		$raw     = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
		$request = self::normalize( $raw );
		if ( '' === $request || 0 === strpos( $request, '/wp-admin' ) || 0 === strpos( $request, '/wp-login' ) ) {
			return;
		}

		$rule = self::match( $request );

		/**
		 * Lets Pro extend matching (regex/wildcard rules) after exact
		 * matching misses. Callbacks receive null|array and the request.
		 *
		 * @param array|null $rule    Matched rule row or null.
		 * @param string     $request Normalized "path?query".
		 */
		$rule = apply_filters( 'rdz_match_request', $rule, $request );

		if ( ! $rule ) {
			return;
		}

		$target = isset( $rule['resolved_target'] ) ? $rule['resolved_target'] : $rule['target'];

		// Runtime self-redirect guard: never bounce a request to itself.
		if ( self::is_internal( $target ) && self::normalize( $target ) === $request ) {
			return;
		}

		self::record_hit( (int) $rule['id'] );

		$status = (int) $rule['redirect_type'];
		if ( ! in_array( $status, RDZ_Settings::$redirect_types, true ) ) {
			$status = 301;
		}

		wp_redirect( esc_url_raw( $target ), $status, 'RedirectZen' ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- targets may be external, owner-configured.
		exit;
	}

	/**
	 * Finds an active exact-match rule for a normalized request. When the
	 * request has a query string and no exact rule matches, falls back to a
	 * path-only match so "/old-page?utm_source=x" still hits "/old-page".
	 *
	 * @param string $request Normalized "path?query".
	 * @return array|null Rule row.
	 */
	public static function match( $request ) {
		$rule = self::find_rule( $request );
		if ( $rule ) {
			return $rule;
		}

		$q_pos = strpos( $request, '?' );
		if ( false !== $q_pos ) {
			return self::find_rule( substr( $request, 0, $q_pos ) );
		}

		return null;
	}

	/**
	 * Looks up an active, non-regex rule by its normalized source.
	 *
	 * @param string $normalized Normalized "path?query".
	 * @return array|null
	 */
	public static function find_rule( $normalized ) {
		global $wpdb;

		$table = self::table();
		$row   = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE source_hash = %s AND is_active = 1 AND is_regex = 0", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				md5( $normalized )
			),
			ARRAY_A
		);

		return $row ? $row : null;
	}

	/**
	 * Looks up a rule by source regardless of active state (dupe checks).
	 *
	 * @param string $normalized Normalized source.
	 * @return array|null
	 */
	public static function find_any_rule( $normalized ) {
		global $wpdb;

		$table = self::table();
		$row   = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE source_hash = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				md5( $normalized )
			),
			ARRAY_A
		);

		return $row ? $row : null;
	}

	/**
	 * Increments the hit counter.
	 *
	 * @param int $id Rule ID.
	 */
	public static function record_hit( $id ) {
		global $wpdb;

		$table = self::table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET hits = hits + 1, last_hit = %s WHERE id = %d", current_time( 'mysql', true ), $id ) );
	}

	/**
	 * Validates a source/target pair. Pure logic shared by add and edit.
	 *
	 * @param string $normalized Normalized source.
	 * @param string $target     Target URL (absolute) or internal path.
	 * @return true|WP_Error
	 */
	public static function validate_pair( $normalized, $target ) {
		if ( '' === $normalized ) {
			return new WP_Error( 'rdz_bad_source', __( 'Please enter a valid source URL or path.', 'redirectzen-301-redirect-manager' ) );
		}

		if ( '/' === $normalized ) {
			return new WP_Error( 'rdz_bad_source', __( 'The homepage cannot be a redirect source.', 'redirectzen-301-redirect-manager' ) );
		}

		// Redirecting the login or admin area is how sites get bricked.
		if ( 0 === strpos( $normalized, '/wp-admin' ) || 0 === strpos( $normalized, '/wp-login' ) ) {
			return new WP_Error( 'rdz_bad_source', __( 'Admin and login URLs cannot be redirected.', 'redirectzen-301-redirect-manager' ) );
		}

		if ( '' === $target ) {
			return new WP_Error( 'rdz_bad_target', __( 'Please enter a target URL or path.', 'redirectzen-301-redirect-manager' ) );
		}

		if ( self::is_internal( $target ) && self::normalize( $target ) === $normalized ) {
			return new WP_Error( 'rdz_loop', __( 'Source and target are the same URL — that would redirect forever.', 'redirectzen-301-redirect-manager' ) );
		}

		return true;
	}

	/**
	 * Walks existing rules from a proposed rule's target and reports what
	 * the new rule would create.
	 *
	 * @param string $normalized Normalized source of the proposed rule.
	 * @param string $target     Proposed target.
	 * @param callable|null $lookup Optional rule lookup fn( string $normalized ): ?array — injectable for tests.
	 * @return string 'loop'|'chain'|'ok'
	 */
	public static function detect_chain( $normalized, $target, $lookup = null ) {
		if ( ! self::is_internal( $target ) ) {
			return 'ok';
		}

		if ( null === $lookup ) {
			$lookup = array( __CLASS__, 'find_rule' );
		}

		$seen    = array( $normalized => true );
		$current = self::normalize( $target );
		$hops    = 0;

		while ( $hops < self::MAX_CHAIN ) {
			if ( isset( $seen[ $current ] ) ) {
				return 'loop';
			}
			$seen[ $current ] = true;

			$rule = call_user_func( $lookup, $current );
			if ( ! $rule ) {
				break;
			}

			if ( ! self::is_internal( $rule['target'] ) ) {
				$hops++;
				break;
			}

			$current = self::normalize( $rule['target'] );
			$hops++;
		}

		if ( $hops >= self::MAX_CHAIN ) {
			return 'loop'; // Too deep to trust — treat like a loop.
		}

		return $hops > 0 ? 'chain' : 'ok';
	}

	/**
	 * Also guard the other direction: does an existing rule point AT the
	 * new source, which would now chain (or loop) through the new rule?
	 * Only used for messaging; the save is still allowed for chains.
	 *
	 * @param string $normalized Normalized source.
	 * @return bool
	 */
	public static function has_inbound_rule( $normalized ) {
		global $wpdb;

		$table = self::table();
		$like  = '%' . $wpdb->esc_like( $normalized ) . '%';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT target FROM {$table} WHERE is_active = 1 AND is_regex = 0 AND target LIKE %s LIMIT 20", $like ), ARRAY_A );

		foreach ( (array) $rows as $row ) {
			if ( self::is_internal( $row['target'] ) && self::normalize( $row['target'] ) === $normalized ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Creates a redirect rule.
	 *
	 * @param string $source        Source URL or path on this site.
	 * @param string $target        Destination URL or internal path.
	 * @param int    $redirect_type 301|302|307.
	 * @param array  $args          { queue_verify?: bool, is_regex?: bool }
	 * @return array|WP_Error The created rule row (with 'chain' flag) or an error.
	 */
	public static function add_rule( $source, $target, $redirect_type = 301, $args = array() ) {
		global $wpdb;

		$is_regex = ! empty( $args['is_regex'] );

		if ( $is_regex ) {
			$normalized = trim( (string) $source );
			if ( '' === $normalized || false === @preg_match( '#' . str_replace( '#', '\#', $normalized ) . '#', '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- validity probe.
				return new WP_Error( 'rdz_bad_regex', __( 'That regular expression does not compile.', 'redirectzen-301-redirect-manager' ) );
			}
			$target = trim( (string) $target );
			$chain  = 'ok';
		} else {
			$normalized = self::normalize( $source );
			$target     = self::sanitize_target( $target );

			$valid = self::validate_pair( $normalized, $target );
			if ( is_wp_error( $valid ) ) {
				return $valid;
			}

			$chain = self::detect_chain( $normalized, $target );
			if ( 'loop' === $chain ) {
				return new WP_Error( 'rdz_loop', __( 'This redirect would create a loop with your existing rules, so it was not saved.', 'redirectzen-301-redirect-manager' ) );
			}
		}

		if ( self::find_any_rule( $normalized ) ) {
			return new WP_Error( 'rdz_duplicate', __( 'A redirect for this source already exists.', 'redirectzen-301-redirect-manager' ) );
		}

		$now      = current_time( 'mysql', true );
		$inserted = $wpdb->insert(
			self::table(),
			array(
				'source'        => $normalized,
				'source_hash'   => md5( $normalized ),
				'target'        => $target,
				'redirect_type' => in_array( (int) $redirect_type, RDZ_Settings::$redirect_types, true ) ? (int) $redirect_type : 301,
				'is_regex'      => $is_regex ? 1 : 0,
				'is_active'     => 1,
				'verify_status' => ( ! isset( $args['queue_verify'] ) || $args['queue_verify'] ) && ! $is_regex ? 'queued' : 'pending',
				'created_at'    => $now,
				'updated_at'    => $now,
			),
			array( '%s', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%s' )
		);

		if ( false === $inserted ) {
			return new WP_Error( 'rdz_db_error', __( 'Could not save the redirect rule.', 'redirectzen-301-redirect-manager' ) );
		}

		$rule          = self::get( (int) $wpdb->insert_id );
		$rule['chain'] = $chain;

		return $rule;
	}

	/**
	 * Records that the user personally created a redirect. Drives the
	 * one-time review ask — which must only ever fire on a GENUINE user
	 * fix, so imports, CSV uploads and programmatic seeding never call
	 * this; only the add form and the 404-log one-click do.
	 */
	public static function record_user_fix() {
		if ( ! get_option( 'rdz_first_redirect_at' ) ) {
			update_option( 'rdz_first_redirect_at', time(), false );
		}
	}

	/**
	 * Updates a rule.
	 *
	 * @param int   $id     Rule ID.
	 * @param array $fields target?, redirect_type?, is_active?, source?
	 * @return array|WP_Error Updated row or error.
	 */
	public static function update_rule( $id, $fields ) {
		global $wpdb;

		$rule = self::get( $id );
		if ( ! $rule ) {
			return new WP_Error( 'rdz_not_found', __( 'Redirect not found.', 'redirectzen-301-redirect-manager' ) );
		}

		$data    = array();
		$formats = array();

		if ( isset( $fields['source'] ) && ! (int) $rule['is_regex'] ) {
			$normalized = self::normalize( $fields['source'] );
			$existing   = self::find_any_rule( $normalized );
			if ( $existing && (int) $existing['id'] !== (int) $id ) {
				return new WP_Error( 'rdz_duplicate', __( 'A redirect for this source already exists.', 'redirectzen-301-redirect-manager' ) );
			}
			$data['source']      = $normalized;
			$data['source_hash'] = md5( $normalized );
			$formats[]           = '%s';
			$formats[]           = '%s';
		}

		$source_for_check = isset( $data['source'] ) ? $data['source'] : $rule['source'];

		if ( isset( $fields['target'] ) ) {
			$target = (int) $rule['is_regex'] ? trim( (string) $fields['target'] ) : self::sanitize_target( $fields['target'] );

			if ( ! (int) $rule['is_regex'] ) {
				$valid = self::validate_pair( $source_for_check, $target );
				if ( is_wp_error( $valid ) ) {
					return $valid;
				}
				if ( 'loop' === self::detect_chain( $source_for_check, $target ) ) {
					return new WP_Error( 'rdz_loop', __( 'This change would create a redirect loop, so it was not saved.', 'redirectzen-301-redirect-manager' ) );
				}
			}

			$data['target'] = $target;
			$formats[]      = '%s';
			// Target changed → previous verification no longer applies.
			$data['verify_status'] = 'queued';
			$formats[]             = '%s';
		}

		if ( isset( $fields['redirect_type'] ) ) {
			$type                  = (int) $fields['redirect_type'];
			$data['redirect_type'] = in_array( $type, RDZ_Settings::$redirect_types, true ) ? $type : 301;
			$formats[]             = '%d';
		}

		if ( isset( $fields['is_active'] ) ) {
			$data['is_active'] = empty( $fields['is_active'] ) ? 0 : 1;
			$formats[]         = '%d';
		}

		if ( empty( $data ) ) {
			return $rule;
		}

		$data['updated_at'] = current_time( 'mysql', true );
		$formats[]          = '%s';

		$updated = $wpdb->update( self::table(), $data, array( 'id' => (int) $id ), $formats, array( '%d' ) );
		if ( false === $updated ) {
			return new WP_Error( 'rdz_db_error', __( 'Could not update the redirect rule.', 'redirectzen-301-redirect-manager' ) );
		}

		return self::get( $id );
	}

	/**
	 * Deletes rules by ID.
	 *
	 * @param int[] $ids Rule IDs.
	 * @return int Rows deleted.
	 */
	public static function delete_rules( $ids ) {
		global $wpdb;

		$ids = array_filter( array_map( 'absint', (array) $ids ) );
		if ( empty( $ids ) ) {
			return 0;
		}

		$table        = self::table();
		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids ) );
	}

	/**
	 * Fetches one rule.
	 *
	 * @param int $id Rule ID.
	 * @return array|null
	 */
	public static function get( $id ) {
		global $wpdb;

		$table = self::table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", (int) $id ), ARRAY_A );

		return $row ? $row : null;
	}

	/**
	 * Cleans a submitted target: allow absolute http(s) URLs and internal
	 * paths (which are stored as-is and resolved against home_url at
	 * redirect time via esc_url_raw handling).
	 *
	 * @param string $target Raw target.
	 * @return string Clean target ('' if unusable).
	 */
	public static function sanitize_target( $target ) {
		$target = trim( (string) $target );
		if ( '' === $target ) {
			return '';
		}

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

		// Bare path → absolute URL on this site.
		if ( '/' !== $target[0] ) {
			$target = '/' . $target;
		}

		return esc_url_raw( home_url( $target ) );
	}

	/**
	 * Status counts for the dashboard/grade, active rules only.
	 *
	 * @return array { total, ok, fail, warn, pending, queued, inactive, regex }
	 */
	public static function get_counts() {
		global $wpdb;

		$table = self::table();
		$rows  = $wpdb->get_results( "SELECT verify_status, is_active, is_regex, COUNT(*) AS n FROM {$table} GROUP BY verify_status, is_active, is_regex", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		$counts = array(
			'total'    => 0,
			'ok'       => 0,
			'fail'     => 0,
			'warn'     => 0,
			'pending'  => 0,
			'queued'   => 0,
			'inactive' => 0,
			'regex'    => 0,
		);

		foreach ( (array) $rows as $row ) {
			$n = (int) $row['n'];
			if ( ! (int) $row['is_active'] ) {
				$counts['inactive'] += $n;
				continue;
			}
			$counts['total'] += $n;
			if ( (int) $row['is_regex'] ) {
				$counts['regex'] += $n;
			}
			$status = $row['verify_status'];
			if ( isset( $counts[ $status ] ) ) {
				$counts[ $status ] += $n;
			}
		}

		return $counts;
	}
}
