<?php
/**
 * Redirects processor class.
 *
 * Processes posts to remove redirect URLs from content.
 * Shared logic used by both CLI and REST API.
 *
 * @link          https://aleksandrsamokhin.com
 * @since         1.0.0
 *
 * @author        Aleksandr Samokhin
 * @package       Redicl\Core
 *
 * @copyright (c) 2025, Aleksandr Samokhin
 */

namespace Redicl\Core;

// Abort if called directly.
defined( 'WPINC' ) || die;

/**
 * Processor class for removing redirect URLs from post content.
 */
class Redirects_Processor {

	/**
	 * Cached posts data, keyed by post types hash.
	 *
	 * @var array
	 */
	private static $cached_posts = array();

	/**
	 * Get all published posts from database.
	 *
	 * Uses caching to avoid multiple database queries.
	 * Note: For large sites with many posts, ensure PHP memory_limit is set to at least 512M.
	 *
	 * @param array $post_types Array of post types to include. Default: array( 'post' ).
	 * @return array Array of post objects with ID, post_title, post_type, post_content.
	 */
	public static function get_all_posts( array $post_types = array( 'post' ) ) {
		// Create a cache key based on post types.
		$cache_key = md5( wp_json_encode( $post_types ) );

		if ( isset( self::$cached_posts[ $cache_key ] ) ) {
			return self::$cached_posts[ $cache_key ];
		}

		global $wpdb;

		// Sanitize and build post_type IN clause.
		$sanitized_types = array_map( 'sanitize_key', $post_types );
		$placeholders    = implode( ', ', array_fill( 0, count( $sanitized_types ), '%s' ) );

		// Build the query with dynamic post types.
		self::$cached_posts[ $cache_key ] = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				"SELECT ID, post_title, post_type, post_content 
				FROM {$wpdb->posts}
				WHERE post_status = 'publish' AND post_type IN ($placeholders)",
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				...$sanitized_types
			)
		);

		return self::$cached_posts[ $cache_key ];
	}

	/**
	 * Clear the cached posts.
	 *
	 * @return void
	 */
	public static function clear_posts_cache() {
		self::$cached_posts = array();
	}

	/**
	 * Build exact URL patterns for matching redirect URLs.
	 *
	 * Creates regex patterns that match URLs in various formats:
	 * - Full URL with and without trailing slash
	 * - Relative URL with and without trailing slash
	 * - Uses negative lookahead to prevent partial matches
	 *
	 * @param string $url      The redirect URL to match (without leading slash).
	 * @param string $home_url The site's home URL.
	 * @return array Array of regex patterns (already escaped with preg_quote).
	 */
	public static function build_url_patterns( $url, $home_url ) {
		$escaped_home_url = preg_quote( $home_url, '/' );
		$escaped_url      = preg_quote( $url, '/' );

		return array(
			// Absolute URL to home site (with or without trailing slash).
			$escaped_home_url . '\/' . $escaped_url . '\/?',
			// Relative URL (with or without trailing slash).
			'\/' . $escaped_url . '\/?',
		);
	}

	/**
	 * Filter posts that contain any of the redirect URLs.
	 *
	 * Uses exact regex pattern matching to avoid false positives.
	 * Only matches URLs within anchor tags with proper boundaries.
	 *
	 * @param array  $all_posts     Array of post objects.
	 * @param array  $redirect_urls Array of redirect URLs to search for.
	 * @param string $format        Optional. Return format: 'objects' (default) or 'formatted'.
	 *                              When 'formatted', returns array grouped by URL with post data (id, title, edit_url).
	 *                              When 'objects', returns array of post objects.
	 * @return array Filtered array of posts containing at least one redirect URL.
	 */
	public static function filter_posts_with_urls( array $all_posts, array $redirect_urls, string $format = 'objects' ) {
		// Cache home_url() to avoid repeated calls (performance optimization).
		$home_url = home_url();

		if ( 'formatted' === $format ) {
			// OPTIMIZED: Use hash lookup instead of nested loops.
			// Build a Set of redirect URLs for O(1) lookup.
			$url_set = array_flip( $redirect_urls );

			// Initialize results for all URLs.
			$results = array();
			foreach ( $redirect_urls as $url ) {
				$results[ $url ] = array();
			}

			// For each post, extract all hrefs and check against the URL set.
			foreach ( $all_posts as $post ) {
				// Extract all href values from anchor tags in one pass.
				if ( preg_match_all( '/<a[^>]*href=["\']([^"\']+)["\'][^>]*>/is', $post->post_content, $matches ) ) {
					foreach ( $matches[1] as $href ) {
						// Normalize the href to match our URL format.
						$normalized = ltrim( $href, '/' );
						// Remove home_url prefix if present.
						if ( strpos( $href, $home_url ) === 0 ) {
							$normalized = ltrim( substr( $href, strlen( $home_url ) ), '/' );
						}
						// Remove trailing slash for comparison.
						$normalized = rtrim( $normalized, '/' );

						// O(1) lookup in our URL set.
						if ( isset( $url_set[ $normalized ] ) ) {
							$results[ $normalized ][] = array(
								'id'       => $post->ID,
								'title'    => $post->post_title,
								'edit_url' => admin_url( 'post.php?post=' . $post->ID . '&action=edit' ),
							);
						}
					}
				}
			}

			return $results;
		} else {
			// Return post objects (default behavior for CLI).
			// OPTIMIZED: Use hash lookup instead of nested loops.
			$url_set = array_flip( $redirect_urls );
			$results = array();

			foreach ( $all_posts as $post ) {
				// Extract all href values from anchor tags in one pass.
				if ( preg_match_all( '/<a[^>]*href=["\']([^"\']+)["\'][^>]*>/is', $post->post_content, $matches ) ) {
					foreach ( $matches[1] as $href ) {
						// Normalize the href to match our URL format.
						$normalized = ltrim( $href, '/' );
						// Remove home_url prefix if present.
						if ( strpos( $href, $home_url ) === 0 ) {
							$normalized = ltrim( substr( $href, strlen( $home_url ) ), '/' );
						}
						// Remove trailing slash for comparison.
						$normalized = rtrim( $normalized, '/' );

						// O(1) lookup in our URL set.
						if ( isset( $url_set[ $normalized ] ) ) {
							$results[] = $post;
							break; // Post matches at least one URL, no need to check more.
						}
					}
				}
			}

			return $results;
		}
	}

	/**
	 * Find posts for multiple URLs at once (convenience wrapper).
	 *
	 * Optimized batch processing - queries posts once, then checks all URLs in PHP.
	 * Uses exact regex pattern matching to avoid false positives.
	 * Only matches URLs within anchor tags with proper boundaries.
	 *
	 * Note: For large sites with many posts, ensure PHP memory_limit is set to at least 512M.
	 *
	 * @param array $urls       Array of URLs to search for (without leading slash).
	 * @param array $post_types Array of post types to include. Default: array( 'post' ).
	 * @return array Associative array mapping URLs to their found posts with formatted data (id, title, edit_url).
	 */
	public static function find_posts_for_urls( array $urls, array $post_types = array( 'post' ) ) {
		$all_posts = self::get_all_posts( $post_types );
		return self::filter_posts_with_urls( $all_posts, $urls, 'formatted' );
	}

	/**
	 * Process a single post to remove redirect URL links.
	 *
	 * @param object $post          Post object with ID, post_title, post_content.
	 * @param array  $redirect_urls Array of redirect URLs to remove.
	 * @param bool   $dry_run       If true, don't actually update the post.
	 * @return array Result array with keys: updated, links_removed, error, post_id, post_title.
	 */
	public static function process_single_post( $post, array $redirect_urls, bool $dry_run = true ) {
		$content          = $post->post_content;
		$original_content = $content;
		$links_removed    = 0;

		// Cache home_url() to avoid repeated calls (performance optimization).
		$home_url = home_url();

		// Check each redirect URL against this post's content.
		foreach ( $redirect_urls as $url ) {
			// Quick check: skip if URL not in content.
			if ( strpos( $content, $url ) === false ) {
				continue;
			}

			// Build exact URL patterns to match.
			$exact_patterns = self::build_url_patterns( $url, $home_url );

			// Create regex pattern.
			$pattern = '/<a[^>]*href=["\'](' . implode( '|', $exact_patterns ) . ')["\'][^>]*>(.*?)<\/a>/is';

			preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER );

			if ( empty( $matches ) ) {
				continue;
			}

			// Process only internal links.
			foreach ( $matches as $match ) {
				$href = $match[1];

				if ( strpos( $href, '/' ) === 0 || strpos( $href, $home_url ) !== false ) {
					// Replace this specific link with just its text.
					$content = str_replace( $match[0], $match[2], $content );
					++$links_removed;
				}
			}
		}

		$result = array(
			'post_id'       => $post->ID,
			'post_title'    => $post->post_title,
			'links_removed' => $links_removed,
			'updated'       => false,
			'error'         => null,
		);

		// Update the post if content has changed.
		if ( $content !== $original_content ) {
			if ( ! $dry_run ) {
				global $wpdb;

				$updated = $wpdb->update(
					$wpdb->posts,
					array(
						'post_content' => $content,
					),
					array( 'ID' => $post->ID ),
					array( '%s' ),
					array( '%d' )
				);

				if ( false !== $updated ) {
					// Clear post cache to ensure WordPress caches are invalidated.
					clean_post_cache( $post->ID );

					// Clear any object cache for this post.
					wp_cache_delete( $post->ID, 'posts' );

					$result['updated'] = true;
				} else {
					$result['error'] = 'Failed to update post in database';
				}
			} else {
				// In dry-run mode, mark as would be updated.
				$result['updated'] = true;
			}
		}

		return $result;
	}

	/**
	 * Process multiple posts to remove redirect URL links.
	 *
	 * @param array $posts            Array of post objects to process.
	 * @param array $redirect_urls    Array of redirect URLs to remove.
	 * @param bool  $dry_run          If true, don't actually update posts.
	 * @param int   $max_links_removed Maximum number of links to remove. 0 means unlimited. Default: 0.
	 * @return array Results array with summary and details.
	 */
	public static function process_posts( array $posts, array $redirect_urls, bool $dry_run = true, int $max_links_removed = 0 ) {
		$posts_checked = 0;
		$posts_updated = 0;
		$links_removed = 0;
		$posts_skipped = 0;
		$details       = array();

		foreach ( $posts as $post ) {
			++$posts_checked;

			// Check if we've already reached or exceeded the limit.
			if ( $max_links_removed > 0 && $links_removed >= $max_links_removed ) {
				break;
			}

			// First, do a dry run to see how many links this post would remove.
			// This allows us to check the limit before actually processing (in actual mode).
			$dry_run_result = self::process_single_post( $post, $redirect_urls, true );

			if ( $dry_run_result['updated'] ) {
				// Calculate how many links this post would add.
				$potential_links = $links_removed + $dry_run_result['links_removed'];

				// If we have a limit and this would exceed it, skip this post.
				// Allow processing if it would bring us to exactly the limit (potential_links <= max_links_removed).
				if ( $max_links_removed > 0 && $potential_links > $max_links_removed ) {
					// Skip this post as it would exceed the limit.
					++$posts_skipped;
					break; // Stop processing remaining posts.
				}

				// If we're in actual mode, process the post for real now.
				// We already know it won't exceed the limit.
				if ( ! $dry_run ) {
					$result = self::process_single_post( $post, $redirect_urls, false );
					// Use the actual result, but links_removed should match the dry run.
					$result['links_removed'] = $dry_run_result['links_removed'];
				} else {
					// In dry run mode, we already have the result.
					$result = $dry_run_result;
				}

				++$posts_updated;
				$links_removed += $result['links_removed'];
				$details[]      = $result;

				// After processing, check if we've reached the limit and stop.
				if ( $max_links_removed > 0 && $links_removed >= $max_links_removed ) {
					break;
				}
			} else {
				++$posts_skipped;
			}
		}

		return array(
			'posts_checked' => $posts_checked,
			'posts_updated' => $posts_updated,
			'links_removed' => $links_removed,
			'posts_skipped' => $posts_skipped,
			'dry_run'       => $dry_run,
			'details'       => $details,
		);
	}

	/**
	 * Get homepage redirects with full data.
	 *
	 * Cached wrapper around Redirects_Helper for convenience.
	 *
	 * @param int $limit Optional. Limit the number of redirects to return.
	 * @return array Array of homepage redirects with full redirect data, keyed by source URL.
	 */
	public static function get_homepage_redirects( int $limit = 0 ) {
		$cache_key        = 'redicl_homepage_redirects';
		$cached_redirects = get_transient( $cache_key );

		if ( false === $cached_redirects ) {
			$redirects        = Redirects_Helper::get_redirects( array() );
			$cached_redirects = Redirects_Helper::get_homepage_redirects( $redirects, 0 );
			set_transient( $cache_key, $cached_redirects, 300 );
		}

		// Apply limit after cache retrieval if specified.
		if ( $limit > 0 ) {
			return array_slice( $cached_redirects, 0, $limit, true );
		}

		return $cached_redirects;
	}


	/**
	 * Detect redirect chains.
	 *
	 * @param int $min_chain_length Minimum chain length.
	 * @return array Array of redirect chains.
	 */
	public static function detect_redirect_chains( int $min_chain_length = 3 ) {
		$redirects = Redirects_Helper::get_redirects( array() );

		$chains  = array();
		$visited = array(); // Cache to avoid reprocessing

		foreach ( $redirects as $source => $redirect_data ) {
			// Skip if already processed as part of another chain
			if ( isset( $visited[ $source ] ) ) {
				continue;
			}

			$chain              = array( $source );
			$current            = $source;
			$seen_in_this_chain = array( $source => true ); // Detect loop redirects

			// Follow the chain iteratively
			while ( isset( $redirects[ $current ]['url'] ) ) {
				$next = $redirects[ $current ]['url'];

				// Check for loop redirect
				if ( isset( $seen_in_this_chain[ $next ] ) ) {
					$chains[ $source ] = array(
						'chain'     => $chain,
						'final_url' => null,
						'length'    => count( $chain ),
						'loop'      => true,
					);
					break;
				}

				$chain[]                     = $next;
				$seen_in_this_chain[ $next ] = true;
				$current                     = $next;
			}

			// Store chain if it meets minimum length
			$chain_length = count( $chain );
			if ( $chain_length >= $min_chain_length ) {
				$chains[ $source ] = array(
					'chain'     => $chain,
					'final_url' => end( $chain ),
					'length'    => $chain_length,
					'loop'      => false,
				);

				// Mark all URLs in this chain as visited
				foreach ( $chain as $url ) {
					$visited[ $url ] = $source; // Reference to chain start
				}
			}
		}

		return $chains;
	}


	/**
	 * Fix redirect chains by consolidating them into direct redirects.
	 *
	 * For chain A → B → C → D:
	 * - Updates A to point directly to D (the final destination)
	 * - Removes intermediate redirects B and C (they become redundant)
	 *
	 * @param int  $min_chain_length Minimum chain length to process. Default: 3.
	 * @param bool $dry_run          If true, don't actually update the option. Default: true.
	 * @param int  $max_redirects     Maximum number of redirects to process (updated + removed). 0 means unlimited. Default: 0.
	 * @return array Results array with summary and details of changes.
	 */
	public static function fix_chain_redirects( int $min_chain_length = 3, bool $dry_run = true, int $max_redirects = 0 ) {
		// Get all redirects.
		$redirects = Redirects_Helper::get_redirects( array() );

		if ( empty( $redirects ) ) {
			return array(
				'success'           => true,
				'chains_fixed'      => 0,
				'redirects_updated' => 0,
				'redirects_removed' => 0,
				'dry_run'           => $dry_run,
				'details'           => array(),
				'message'           => 'No redirects found.',
			);
		}

		// Detect chains.
		$chains = self::detect_redirect_chains( $min_chain_length );

		if ( empty( $chains ) ) {
			return array(
				'success'           => true,
				'chains_fixed'      => 0,
				'redirects_updated' => 0,
				'redirects_removed' => 0,
				'dry_run'           => $dry_run,
				'details'           => array(),
				'message'           => esc_html__( 'No redirect chains found.', 'redirect-cleanup' ),
			);
		}

		$chains_fixed          = 0;
		$redirects_updated     = 0;
		$redirects_removed     = 0;
		$details               = array();
		$urls_to_remove        = array();
		$urls_actually_removed = array(); // Track which URLs were actually removed.

		// For free users, limit processing to max_redirects updated redirects.
		// Base and plain redirects are in sync, so we count them as one set.
		$max_updated_redirects  = 0;
		$updated_redirects_done = 0;

		if ( $max_redirects > 0 ) {
			// Only count updated redirects (not removed ones).
			// Base and plain are in sync, so count once.
			$max_updated_redirects = $max_redirects;
		}

		// Process each chain.
		foreach ( $chains as $source_url => $chain_data ) {
			// Skip loop redirects - they need manual intervention.
			if ( $chain_data['loop'] ) {
				$details[] = array(
					'source_url' => $source_url,
					'action'     => 'skipped',
					'reason'     => esc_html__( 'Loop redirect detected', 'redirect-cleanup' ),
					'chain'      => $chain_data['chain'],
				);
				continue;
			}

			$chain     = $chain_data['chain'];
			$final_url = $chain_data['final_url'];

			// The first URL in the chain is the source.
			$first_url = $chain[0];

			// Collect intermediate URLs to remove (all except first and last).
			$intermediate_urls = array_slice( $chain, 1, -1 );

			// Check if this chain would update a redirect in plain redirects.
			$would_update_plain = isset( $redirects[ $first_url ] );

			// Check if we've reached the limit for updated redirects.
			if ( $max_updated_redirects > 0 && $would_update_plain && $updated_redirects_done >= $max_updated_redirects ) {
				// Skip this chain if we've reached the updated redirects limit.
				continue;
			}

			// Update the first redirect to point to final destination.
			if ( $would_update_plain ) {
				$old_target                     = $redirects[ $first_url ]['url'];
				$redirects[ $first_url ]['url'] = $final_url;
				++$redirects_updated;
				++$updated_redirects_done;

				$details[] = array(
					'source_url' => $first_url,
					'action'     => 'updated',
					'old_target' => $old_target,
					'new_target' => $final_url,
					'chain'      => $chain,
				);
			}

			// Mark intermediate redirects for removal.
			foreach ( $intermediate_urls as $url ) {
				$urls_to_remove[ $url ] = true;
			}

			++$chains_fixed;
		}

		// Remove intermediate redirects (no limit check - we only count updated redirects).
		foreach ( $urls_to_remove as $url => $_ ) {
			if ( isset( $redirects[ $url ] ) ) {
				unset( $redirects[ $url ] );
				$urls_actually_removed[ $url ] = true; // Track that this URL was actually removed.
				++$redirects_removed;
			}
		}

		// Also process the base redirects option (numerically-indexed format).
		$base_redirects         = Redirects_Helper::get_base_redirects( array() );
		$base_redirects_updated = 0;
		$base_redirects_removed = 0;

		if ( ! empty( $base_redirects ) ) {
			// Build a lookup map from origin to array index for O(1) access.
			$origin_to_index = array();
			foreach ( $base_redirects as $index => $redirect_data ) {
				if ( isset( $redirect_data['origin'] ) ) {
					$origin_to_index[ $redirect_data['origin'] ] = $index;
				}
			}

			// Collect updates to apply: first_url => final_url.
			$updates_to_apply = array();
			foreach ( $details as $detail ) {
				if ( 'updated' === $detail['action'] ) {
					$updates_to_apply[ $detail['source_url'] ] = $detail['new_target'];
				}
			}

			// Update matching entries in base redirects (no limit check - updates don't count toward limit).
			foreach ( $updates_to_apply as $origin => $new_target ) {
				if ( isset( $origin_to_index[ $origin ] ) ) {
					$index                           = $origin_to_index[ $origin ];
					$base_redirects[ $index ]['url'] = $new_target;
					++$base_redirects_updated;
				}
			}

			// Remove intermediate redirects from base redirects.
			// Only remove URLs that were actually removed from plain redirects.
			// Base and plain are in sync, so we don't count these separately.
			foreach ( $urls_actually_removed as $url => $_ ) {
				if ( isset( $origin_to_index[ $url ] ) ) {
					$index = $origin_to_index[ $url ];
					unset( $base_redirects[ $index ] );
					++$base_redirects_removed;
				}
			}

			// Re-index the array to maintain sequential numeric keys.
			$base_redirects = array_values( $base_redirects );
		}

		// Update both options if not in dry-run mode.
		if ( ! $dry_run && ( $redirects_updated > 0 || $redirects_removed > 0 ) ) {
			$updated = Redirects_Helper::update_redirects( $redirects );

			if ( ! $updated ) {
				return array(
					'success'                => false,
					'chains_fixed'           => 0,
					'redirects_updated'      => 0,
					'redirects_removed'      => 0,
					'base_redirects_updated' => 0,
					'base_redirects_removed' => 0,
					'dry_run'                => $dry_run,
					'details'                => array(),
					'message'                => esc_html__( 'Failed to update redirects option.', 'redirect-cleanup' ),
				);
			}

			// Update the base redirects option as well.
			if ( $base_redirects_updated > 0 || $base_redirects_removed > 0 ) {
				Redirects_Helper::update_base_redirects( $base_redirects );
			}

			// Clear any cached chain data.
			delete_transient( 'redirect_cleanup_homepage_redirects' );
		}

		return array(
			'success'                => true,
			'chains_fixed'           => $chains_fixed,
			'redirects_updated'      => $redirects_updated,
			'redirects_removed'      => $redirects_removed,
			'base_redirects_updated' => $base_redirects_updated,
			'base_redirects_removed' => $base_redirects_removed,
			'dry_run'                => $dry_run,
			'details'                => $details,
			'message'                => sprintf(
				/* translators: 1: plural number of chains fixed. */
				/* translators: 2: plural number of redirects updated. */
				/* translators: 3: plural number of redirects removed. */
				_n(
					'%1$d chain fixed: %2$d redirect updated, %3$d redirect removed.',
					'%1$d chains fixed: %2$d redirects updated, %3$d redirects removed.',
					$chains_fixed,
					'redirect-cleanup'
				),
				$chains_fixed,
				$redirects_updated,
				$redirects_removed
			),
		);
	}

	/**
	 * Get homepage redirect URLs.
	 *
	 * Wrapper around get_homepage_redirects() that extracts just the URLs.
	 *
	 * @param int $limit Optional. Limit the number of URLs to return.
	 * @return array Array of source URLs (without leading slash).
	 */
	public static function get_homepage_redirect_urls( int $limit = 0 ) {
		$homepage_redirects = self::get_homepage_redirects( $limit );
		return array_keys( $homepage_redirects );
	}
}
