<?php
/**
 * Broken Link Checker API Controller.
 *
 * Scans published posts for broken internal/external links, detects
 * redirect chains, and stores results for UI display. Scheduled scans
 * run via WP-Cron.
 *
 * @package Antimanual
 * @since   3.6.0
 */

namespace Antimanual\Api;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * REST controller and WP-Cron worker for the Broken Link Checker.
 */
class BrokenLinkController {
	/** Option key where scan results are persisted. */
	private const RESULTS_OPTION = 'antimanual_broken_links_results';

	/** Option key where the scheduled-scan config is persisted. */
	private const SCHEDULE_OPTION = 'antimanual_broken_links_schedule';

	/** Option key for URLs the user has chosen to ignore in future scans. */
	private const IGNORED_OPTION = 'antimanual_broken_links_ignored';

	/** Option key for the currently-running scan job state (chunked progress). */
	private const SCAN_JOB_OPTION = 'antimanual_broken_links_scan_job';

	/** WP-Cron hook fired to run the scheduled scan. */
	private const CRON_HOOK = 'antimanual_broken_links_cron_scan';

	/** WP-Cron hook fired to continue an in-progress scheduled scan in another tick. */
	private const CRON_CONTINUE_HOOK = 'antimanual_broken_links_cron_continue';

	/** Hard cap on posts enumerated for a single scan job — protects very large sites. */
	private const MAX_POSTS_ENUM = 10000;

	/** Soft cap on links checked inside a single scheduled-scan request. */
	private const MAX_LINKS_PER_CRON_TICK = 1000;

	/** Per-chunk wall-clock budget in seconds. Bounds how long one tick processes posts for. */
	private const CHUNK_TIME_BUDGET = 12;

	/** Delay before a continuation cron tick fires when a scheduled scan is mid-job. */
	private const CRON_CONTINUE_DELAY = 5 * MINUTE_IN_SECONDS;

	/** Transient prefix for cached URL-check results — avoids re-hitting the network on each scan. */
	private const URL_CACHE_PREFIX = 'atml_blc_url_';

	/** TTL for cached URL-check results (6 hours, in seconds). Keeps re-scans fast without holding stale data too long. */
	private const URL_CACHE_TTL = 6 * 3600;

	/** Whether the current site uses HTTPS. Lazily resolved via {@see is_https_site()}. */
	private ?bool $is_https_site = null;

	/**
	 * Constructor — wires up the WP-Cron hooks and registers the monthly schedule.
	 */
	public function __construct() {
		add_action( self::CRON_HOOK, [ $this, 'run_scheduled_scan' ] );
		add_action( self::CRON_CONTINUE_HOOK, [ $this, 'continue_scheduled_scan' ] );
		add_filter( 'cron_schedules', [ $this, 'register_cron_schedules' ] );
	}

	/**
	 * Resolve once whether the current site is HTTPS. Used for mixed-content detection.
	 */
	private function is_https_site(): bool {
		if ( null === $this->is_https_site ) {
			$this->is_https_site = 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME );
		}
		return $this->is_https_site;
	}

	/**
	 * Register the 'monthly' WP-Cron interval — not built-in to WordPress.
	 * Without this the monthly schedule silently falls back to the default interval.
	 *
	 * @param array $schedules Existing cron schedules.
	 * @return array
	 */
	public function register_cron_schedules( array $schedules ): array {
		if ( ! isset( $schedules['monthly'] ) ) {
			$schedules['monthly'] = [
				'interval' => 30 * DAY_IN_SECONDS,
				'display'  => __( 'Once Monthly', 'antimanual' ),
			];
		}
		return $schedules;
	}

	/**
	 * Register REST routes.
	 *
	 * @param string $namespace REST namespace.
	 */
	public function register_routes( string $namespace ): void {
		$can_manage = fn() => current_user_can( 'manage_options' );

		$routes = [
			[ '/broken-links/scan/start',  'POST', 'scan_start' ],
			[ '/broken-links/scan/tick',   'POST', 'scan_tick' ],
			[ '/broken-links/scan/cancel', 'POST', 'scan_cancel' ],
			[ '/broken-links/scan/status', 'GET',  'scan_status' ],
			[ '/broken-links/recheck',     'POST', 'recheck' ],
			[ '/broken-links/results',     'GET',  'results' ],
			[ '/broken-links/fix',         'POST', 'fix' ],
			[ '/broken-links/fix-bulk',    'POST', 'fix_bulk' ],
			[ '/broken-links/schedule',    'POST', 'save_schedule' ],
			[ '/broken-links/schedule',    'GET',  'get_schedule' ],
			[ '/broken-links/ignore',      'GET',  'get_ignored' ],
			[ '/broken-links/ignore',      'POST', 'add_ignored' ],
			[ '/broken-links/unignore',    'POST', 'remove_ignored' ],
			[ '/broken-links/clear-cache', 'POST', 'clear_url_cache' ],
		];

		foreach ( $routes as [ $path, $method, $handler ] ) {
			register_rest_route(
				$namespace,
				$path,
				[
					'methods'             => $method,
					'callback'            => [ $this, $handler ],
					'permission_callback' => $can_manage,
				]
			);
		}
	}

	/* --------------------------------------------------------------
	 * REST handlers
	 * -------------------------------------------------------------- */

	/**
	 * Start a new chunked scan job. Enumerates the post IDs to scan and stores
	 * job state; the client then polls scan_tick() to advance the cursor.
	 *
	 * Replacing the previous one-shot /scan endpoint: that path blocked the
	 * request for potentially minutes on large sites and offered no progress.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function scan_start( $request ) {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$post_types = (array) $request->get_param( 'post_types' );
		$post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) );
		if ( empty( $post_types ) ) {
			$post_types = [ 'post', 'page' ];
		}

		$timeout = absint( $request->get_param( 'timeout' ) ?: 8 );
		$timeout = min( max( $timeout, 3 ), 20 );

		$job = $this->create_scan_job( $post_types, $timeout, 'interactive' );
		update_option( self::SCAN_JOB_OPTION, $job, false );

		return rest_ensure_response( [
			'success' => true,
			'data'    => $this->job_to_progress( $job, false ),
		] );
	}

	/**
	 * Advance the active scan job by one time-bounded chunk.
	 *
	 * Returns progress; when the cursor reaches the end the job is finalized,
	 * cached results are written, and the full result payload is included.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function scan_tick( $request ) {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$job_id = sanitize_text_field( (string) ( $request->get_param( 'job_id' ) ?: '' ) );
		$job    = get_option( self::SCAN_JOB_OPTION, null );

		if ( ! is_array( $job ) || '' === $job_id || ( $job['job_id'] ?? '' ) !== $job_id ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'No active scan job.', 'antimanual' ) ],
				404
			);
		}

		$job = $this->process_scan_chunk( $job, self::CHUNK_TIME_BUDGET, 0 );
		$done = (int) $job['cursor'] >= (int) $job['total_posts'];

		if ( $done ) {
			$result = $this->finalize_scan_job( $job );
			delete_option( self::SCAN_JOB_OPTION );
			return rest_ensure_response( [
				'success' => true,
				'data'    => array_merge(
					$this->job_to_progress( $job, true ),
					[ 'results' => $result ]
				),
			] );
		}

		update_option( self::SCAN_JOB_OPTION, $job, false );
		return rest_ensure_response( [
			'success' => true,
			'data'    => $this->job_to_progress( $job, false ),
		] );
	}

	/**
	 * Cancel the active scan job.
	 *
	 * @return \WP_REST_Response
	 */
	public function scan_cancel() {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}
		delete_option( self::SCAN_JOB_OPTION );
		return rest_ensure_response( [ 'success' => true, 'data' => null ] );
	}

	/**
	 * Return the current scan-job progress, if any. Used by the UI to resume
	 * after a page reload while a scan is mid-flight.
	 *
	 * @return \WP_REST_Response
	 */
	public function scan_status() {
		$job = get_option( self::SCAN_JOB_OPTION, null );
		if ( ! is_array( $job ) ) {
			return rest_ensure_response( [ 'success' => true, 'data' => null ] );
		}
		return rest_ensure_response( [
			'success' => true,
			'data'    => $this->job_to_progress( $job, false ),
		] );
	}

	/**
	 * Re-check a single URL, bypassing the transient cache. Returns the fresh
	 * check result so the UI can update the row in place (or remove it if the
	 * URL is now reachable).
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function recheck( $request ) {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$url     = esc_url_raw( (string) ( $request->get_param( 'url' ) ?: '' ) );
		$timeout = absint( $request->get_param( 'timeout' ) ?: 8 );
		$timeout = min( max( $timeout, 3 ), 20 );

		if ( '' === $url ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'URL is required.', 'antimanual' ) ],
				400
			);
		}

		// Force-bust the per-URL transient so we genuinely re-hit the network.
		delete_transient( self::URL_CACHE_PREFIX . md5( $url ) );

		$check = $this->check_url( $url, $timeout );

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'url'    => $url,
				'status' => $check['status'],
				'code'   => (int) $check['code'],
				'chain'  => array_values( (array) ( $check['chain'] ?? [] ) ),
				'issues' => array_values( (array) ( $check['issues'] ?? [] ) ),
				'ok'     => 'ok' === $check['status'],
			],
		] );
	}

	/**
	 * Apply a fix (update URL or remove anchor) to multiple links in bulk.
	 * Each entry is processed independently; failures are collected and
	 * returned in the response rather than aborting the batch.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function fix_bulk( $request ) {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$rows = (array) ( $request->get_param( 'rows' ) ?: [] );
		if ( empty( $rows ) ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'No rows provided.', 'antimanual' ) ],
				400
			);
		}

		$results  = [];
		$errors   = [];

		foreach ( $rows as $item ) {
			$post_id = absint( $item['post_id'] ?? 0 );
			$old_url = esc_url_raw( (string) ( $item['old_url'] ?? '' ) );
			$new_url = esc_url_raw( (string) ( $item['new_url'] ?? '' ) );
			$action  = sanitize_key( (string) ( $item['action'] ?? 'remove' ) );

			if ( $post_id <= 0 || '' === $old_url ) {
				$errors[] = $old_url ?: "post:$post_id";
				continue;
			}

			$post = get_post( $post_id );
			if ( ! $post instanceof \WP_Post ) {
				$errors[] = $old_url;
				continue;
			}

			$content  = (string) $post->post_content;
			$old_quoted = preg_quote( $old_url, '/' );
			$replaced = 0;
			$updated  = $content;

			if ( 'remove' === $action ) {
				$updated = preg_replace_callback(
					'/<a\b[^>]*href\s*=\s*["\']' . $old_quoted . '["\'][^>]*>(.*?)<\/a>/is',
					function ( $m ) use ( &$replaced ) {
						++$replaced;
						return $m[1];
					},
					$content
				);
			} else {
				$safe_new = esc_url( $new_url );
				if ( '' === $safe_new ) {
					$errors[] = $old_url;
					continue;
				}
				$updated = preg_replace_callback(
					'/(href\s*=\s*["\'])' . $old_quoted . '(["\'])/i',
					function ( $m ) use ( $safe_new, &$replaced ) {
						++$replaced;
						return $m[1] . $safe_new . $m[2];
					},
					$content
				);
			}

			if ( $replaced > 0 && $updated !== $content ) {
				wp_update_post( [
					'ID'           => $post_id,
					'post_content' => $updated,
				] );
				$this->remove_link_from_results_cache( $post_id, $old_url );
			}

			$results[] = [
				'post_id'  => $post_id,
				'url'      => $old_url,
				'replaced' => $replaced,
				'action'   => $action,
			];
		}

		return rest_ensure_response( [
			'success' => true,
			'data'    => [ 'results' => $results, 'errors' => $errors ],
		] );
	}

	/**
	 * Delete all per-URL transient cache entries. Forces the next scan to
	 * re-check every URL over the network instead of reading stale 6-hour
	 * cached results. Useful when the user has fixed broken URLs externally.
	 *
	 * @return \WP_REST_Response
	 */
	public function clear_url_cache() {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		global $wpdb;
		$prefix     = '_transient_' . self::URL_CACHE_PREFIX;
		$timeout_pfx = '_transient_timeout_' . self::URL_CACHE_PREFIX;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$deleted = (int) $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
				$wpdb->esc_like( $prefix ) . '%',
				$wpdb->esc_like( $timeout_pfx ) . '%'
			)
		);

		return rest_ensure_response( [
			'success' => true,
			'data'    => [ 'deleted' => $deleted ],
		] );
	}

	/**
	 * Return the latest cached scan results.
	 *
	 * @return \WP_REST_Response
	 */
	public function results() {
		$data = get_option( self::RESULTS_OPTION, null );
		if ( ! is_array( $data ) ) {
			$data = [
				'scanned_at'    => null,
				'posts_scanned' => 0,
				'total_posts'   => 0,
				'links_checked' => 0,
				'broken'        => [],
				'redirects'     => [],
				'summary'       => [ 'broken' => 0, 'redirects' => 0, 'mixed_content' => 0 ],
				'enum_capped'   => false,
			];
		} else {
			$data['total_posts'] = (int) ( $data['total_posts'] ?? ( $data['posts_scanned'] ?? 0 ) );
			$data['enum_capped'] = (bool) ( $data['enum_capped'] ?? false );
		}
		return rest_ensure_response( [
			'success' => true,
			'data'    => $data,
		] );
	}

	/**
	 * Apply a fix to a broken link: update URL or remove the anchor entirely.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function fix( $request ) {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$post_id = absint( $request->get_param( 'post_id' ) );
		$old_url = esc_url_raw( (string) $request->get_param( 'old_url' ) );
		$new_url = esc_url_raw( (string) $request->get_param( 'new_url' ) );
		$action  = sanitize_key( (string) ( $request->get_param( 'action' ) ?: 'update' ) );

		if ( $post_id <= 0 || '' === $old_url ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Missing post_id or old_url.', 'antimanual' ) ],
				400
			);
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Post not found.', 'antimanual' ) ],
				404
			);
		}

		$content    = (string) $post->post_content;
		$old_quoted = preg_quote( $old_url, '/' );
		$updated    = $content;
		$replaced   = 0;

		if ( 'remove' === $action ) {
			// Remove <a ...href="OLD"...>text</a> but keep the inner text.
			$updated = preg_replace_callback(
				'/<a\b[^>]*href\s*=\s*["\']' . $old_quoted . '["\'][^>]*>(.*?)<\/a>/is',
				function ( $m ) use ( &$replaced ) {
					++$replaced;
					return $m[1];
				},
				$content
			);
		} else {
			$safe_new_url = esc_url( $new_url );
			if ( '' === $new_url || '' === $safe_new_url ) {
				return new \WP_REST_Response(
					[ 'success' => false, 'message' => __( 'A valid replacement URL is required.', 'antimanual' ) ],
					400
				);
			}
			$updated = preg_replace_callback(
				'/(href\s*=\s*["\'])' . $old_quoted . '(["\'])/i',
				function ( $m ) use ( $safe_new_url, &$replaced ) {
					++$replaced;
					return $m[1] . $safe_new_url . $m[2];
				},
				$content
			);
		}

		if ( $replaced > 0 && $updated !== $content ) {
			wp_update_post( [
				'ID'           => $post_id,
				'post_content' => $updated,
			] );
			$this->remove_link_from_results_cache( $post_id, $old_url );
		}

		return rest_ensure_response( [
			'success' => true,
			'data'    => [
				'post_id'  => $post_id,
				'replaced' => $replaced,
				'action'   => $action,
			],
		] );
	}

	/**
	 * Persist and (re)apply the scheduled-scan config.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function save_schedule( $request ) {
		if ( ! \atml_is_pro_max() ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Pro Max plan required.', 'antimanual' ) ],
				403
			);
		}

		$enabled   = (bool) $request->get_param( 'enabled' );
		$frequency = sanitize_key( (string) ( $request->get_param( 'frequency' ) ?: 'weekly' ) );

		$allowed = [ 'daily', 'weekly', 'monthly' ];
		if ( ! in_array( $frequency, $allowed, true ) ) {
			$frequency = 'weekly';
		}

		$post_types = (array) $request->get_param( 'post_types' );
		$post_types = array_values( array_filter( array_map( 'sanitize_key', $post_types ) ) );
		if ( empty( $post_types ) ) {
			$post_types = [ 'post', 'page' ];
		}

		$email_alerts    = (bool) $request->get_param( 'email_alerts' );
		$email_raw       = trim( (string) ( $request->get_param( 'email_recipient' ) ?: '' ) );
		$email_recipient = '' !== $email_raw ? sanitize_email( $email_raw ) : '';
		if ( '' !== $email_raw && ! is_email( $email_recipient ) ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'Enter a valid recipient email address.', 'antimanual' ) ],
				400
			);
		}

		$schedule = [
			'enabled'         => $enabled,
			'frequency'       => $frequency,
			'post_types'      => $post_types,
			'email_alerts'    => $email_alerts,
			'email_recipient' => $email_recipient,
			'updated_at'      => current_time( 'mysql' ),
		];
		update_option( self::SCHEDULE_OPTION, $schedule, false );

		// Reset existing cron and reschedule if needed.
		$existing = wp_next_scheduled( self::CRON_HOOK );
		if ( $existing ) {
			wp_unschedule_event( $existing, self::CRON_HOOK );
		}
		if ( $enabled ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, $frequency, self::CRON_HOOK );
		}

		return rest_ensure_response( [
			'success' => true,
			'data'    => array_merge( $schedule, [
				'next_run' => wp_next_scheduled( self::CRON_HOOK ) ?: null,
			] ),
		] );
	}

	/**
	 * Return the current schedule config.
	 *
	 * @return \WP_REST_Response
	 */
	public function get_schedule() {
		$defaults = [
			'enabled'         => false,
			'frequency'       => 'weekly',
			'post_types'      => [ 'post', 'page' ],
			'email_alerts'    => false,
			'email_recipient' => '',
		];
		$schedule = wp_parse_args( (array) get_option( self::SCHEDULE_OPTION, [] ), $defaults );
		$schedule['next_run'] = wp_next_scheduled( self::CRON_HOOK ) ?: null;
		return rest_ensure_response( [
			'success' => true,
			'data'    => $schedule,
		] );
	}

	/**
	 * Return the list of ignored URLs.
	 *
	 * @return \WP_REST_Response
	 */
	public function get_ignored(): \WP_REST_Response {
		$ignored = array_values( (array) get_option( self::IGNORED_OPTION, [] ) );
		return rest_ensure_response( [ 'success' => true, 'data' => $ignored ] );
	}

	/**
	 * Add a URL to the ignore list. Ignored URLs are skipped in future scans.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function add_ignored( \WP_REST_Request $request ): \WP_REST_Response {
		// Accept either a single `url` string or a `urls` array for bulk-add.
		$single_raw = trim( (string) $request->get_param( 'url' ) );
		$bulk_raw   = (array) ( $request->get_param( 'urls' ) ?: [] );

		$to_add = [];
		if ( '' !== $single_raw ) {
			if ( preg_match( '/^https?:\/\//i', $single_raw ) ) {
				$cleaned = esc_url_raw( $single_raw );
			} else {
				$cleaned = sanitize_text_field( $single_raw );
			}
			if ( '' !== $cleaned ) {
				$to_add[] = $cleaned;
			}
		}
		foreach ( $bulk_raw as $u ) {
			$u_trimmed = trim( (string) $u );
			if ( '' === $u_trimmed ) {
				continue;
			}
			if ( preg_match( '/^https?:\/\//i', $u_trimmed ) ) {
				$cleaned = esc_url_raw( $u_trimmed );
			} else {
				$cleaned = sanitize_text_field( $u_trimmed );
			}
			if ( '' !== $cleaned ) {
				$to_add[] = $cleaned;
			}
		}

		if ( empty( $to_add ) ) {
			return new \WP_REST_Response(
				[ 'success' => false, 'message' => __( 'URL required.', 'antimanual' ) ],
				400
			);
		}

		$ignored = (array) get_option( self::IGNORED_OPTION, [] );
		foreach ( $to_add as $url ) {
			$normalized = $this->normalize_url_for_comparison( $url );
			if ( ! in_array( $normalized, $ignored, true ) ) {
				$ignored[] = $normalized;
			}
		}
		update_option( self::RESULTS_OPTION, null ); // Clear results transient/option if needed or just update
		update_option( self::IGNORED_OPTION, array_values( $ignored ), false );

		return rest_ensure_response( [ 'success' => true, 'data' => array_values( $ignored ) ] );
	}

	/**
	 * Remove a URL from the ignore list. Omit `url` to clear the entire list.
	 *
	 * @param \WP_REST_Request $request REST request.
	 * @return \WP_REST_Response
	 */
	public function remove_ignored( \WP_REST_Request $request ): \WP_REST_Response {
		$url_param = (string) ( $request->get_param( 'url' ) ?: '' );
		if ( '' === $url_param ) {
			update_option( self::IGNORED_OPTION, [], false );
			return rest_ensure_response( [ 'success' => true, 'data' => [] ] );
		}
		$url = $this->normalize_url_for_comparison( esc_url_raw( $url_param ) );
		$ignored = array_values( array_filter(
			(array) get_option( self::IGNORED_OPTION, [] ),
			fn( $u ) => $this->normalize_url_for_comparison( $u ) !== $url
		) );
		update_option( self::IGNORED_OPTION, $ignored, false );
		return rest_ensure_response( [ 'success' => true, 'data' => $ignored ] );
	}

	/**
	 * WP-Cron hook callback for the recurring scheduled scan. Creates a fresh
	 * job and processes as many chunks as fit in this tick, then either
	 * finalizes (sending an email alert if configured) or schedules a single
	 * continuation event to pick up where this left off.
	 *
	 * Bails when the module is disabled or Pro Max is no longer active, so we
	 * do not silently keep scanning after the user opts out.
	 */
	public function run_scheduled_scan(): void {
		if ( ! \atml_is_module_enabled( 'broken_links' ) || ! \atml_is_pro_max() ) {
			return;
		}
		$schedule = get_option( self::SCHEDULE_OPTION, [] );
		if ( empty( $schedule['enabled'] ) ) {
			return;
		}

		// Drop any stale in-progress job before starting fresh.
		delete_option( self::SCAN_JOB_OPTION );

		$post_types = (array) ( $schedule['post_types'] ?? [ 'post', 'page' ] );
		$job        = $this->create_scan_job( $post_types, 8, 'scheduled' );

		$this->advance_scheduled_job( $job );
	}

	/**
	 * WP-Cron continuation callback — resumes a scheduled-scan job that did
	 * not complete in a single cron tick.
	 */
	public function continue_scheduled_scan(): void {
		if ( ! \atml_is_module_enabled( 'broken_links' ) || ! \atml_is_pro_max() ) {
			return;
		}
		$job = get_option( self::SCAN_JOB_OPTION, null );
		if ( ! is_array( $job ) || 'scheduled' !== ( $job['source'] ?? '' ) ) {
			return;
		}
		$this->advance_scheduled_job( $job );
	}

	/**
	 * Process one cron tick of a scheduled job: run chunks until the link or
	 * time budget is exhausted, then either finalize or reschedule.
	 *
	 * @param array $job Scan job state.
	 */
	private function advance_scheduled_job( array $job ): void {
		$tick_started = microtime( true );
		$soft_budget  = self::CHUNK_TIME_BUDGET * 3; // ~36s aggregate per cron tick
		$links_cap    = self::MAX_LINKS_PER_CRON_TICK;

		while ( true ) {
			$remaining = $soft_budget - ( microtime( true ) - $tick_started );
			if ( $remaining < self::CHUNK_TIME_BUDGET / 2 ) {
				break;
			}
			$job = $this->process_scan_chunk( $job, (int) $remaining, $links_cap );
			if ( (int) $job['cursor'] >= (int) $job['total_posts'] ) {
				break;
			}
			if ( (int) $job['links_checked'] >= $links_cap ) {
				break;
			}
		}

		if ( (int) $job['cursor'] >= (int) $job['total_posts'] ) {
			$previous = get_option( self::RESULTS_OPTION, null );
			$result   = $this->finalize_scan_job( $job );
			delete_option( self::SCAN_JOB_OPTION );
			$this->maybe_send_alert_email( $previous, $result );
			return;
		}

		update_option( self::SCAN_JOB_OPTION, $job, false );
		if ( ! wp_next_scheduled( self::CRON_CONTINUE_HOOK ) ) {
			wp_schedule_single_event( time() + self::CRON_CONTINUE_DELAY, self::CRON_CONTINUE_HOOK );
		}
	}

	/* --------------------------------------------------------------
	 * Scan core
	 * -------------------------------------------------------------- */

	/**
	 * Build a fresh scan job: enumerate the post IDs to scan and return job
	 * state ready to persist. The cap MAX_POSTS_ENUM protects very large sites
	 * but the gap is reported to the UI via total_posts so the limit is no
	 * longer silent.
	 *
	 * @param array<int, string> $post_types Post types to include.
	 * @param int                $timeout    Per-request HTTP timeout (seconds).
	 * @param string             $source     Job origin — 'interactive' or 'scheduled'.
	 * @return array Scan job state.
	 */
	private function create_scan_job( array $post_types, int $timeout, string $source ): array {
		// phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_numberposts
		$post_ids = get_posts( [
			'post_type'              => $post_types,
			'post_status'            => 'publish',
			'numberposts'            => self::MAX_POSTS_ENUM,
			'fields'                 => 'ids',
			'orderby'                => 'modified',
			'order'                  => 'DESC',
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'suppress_filters'       => false,
		] );
		$post_ids = array_map( 'intval', (array) $post_ids );

		return [
			'job_id'        => wp_generate_uuid4(),
			'source'        => 'scheduled' === $source ? 'scheduled' : 'interactive',
			'post_types'    => array_values( $post_types ),
			'post_ids'      => $post_ids,
			'total_posts'   => count( $post_ids ),
			'cursor'        => 0,
			'timeout'       => $timeout,
			'links_checked' => 0,
			'broken'        => [],
			'redirects'     => [],
			'started_at'    => current_time( 'mysql' ),
		];
	}

	/**
	 * Process posts in the job until the time budget is consumed or the
	 * optional link cap is hit. Returns the updated job state.
	 *
	 * @param array $job          Scan job state.
	 * @param int   $time_budget  Wall-clock seconds this chunk may run for.
	 * @param int   $link_cap     Hard cap on links_checked after this chunk; 0 = no cap.
	 * @return array
	 */
	private function process_scan_chunk( array $job, int $time_budget, int $link_cap ): array {
		$started       = microtime( true );
		$time_budget   = max( 2, $time_budget );
		$is_https_site = $this->is_https_site();

		$ignored_set = [];
		foreach ( (array) get_option( self::IGNORED_OPTION, [] ) as $ignored_url ) {
			$ignored_set[ $this->normalize_url_for_comparison( $ignored_url ) ] = true;
		}

		$url_cache     = [];
		$post_ids      = (array) $job['post_ids'];
		$total         = (int) $job['total_posts'];
		$cursor        = (int) $job['cursor'];
		$timeout       = (int) ( $job['timeout'] ?? 8 );
		$broken_rows   = (array) $job['broken'];
		$redirect_rows = (array) $job['redirects'];
		$links_checked = (int) $job['links_checked'];

		while ( $cursor < $total ) {
			if ( microtime( true ) - $started >= $time_budget ) {
				break;
			}
			if ( $link_cap > 0 && $links_checked >= $link_cap ) {
				break;
			}

			$post_id = (int) $post_ids[ $cursor ];
			++$cursor;
			$post = get_post( $post_id );
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}
			$content = (string) $post->post_content;
			if ( '' === trim( $content ) ) {
				continue;
			}
			$links = $this->extract_links_from_content( $content );
			if ( empty( $links ) ) {
				continue;
			}

			$post_title = $post->post_title ?: '';
			$edit_link  = get_edit_post_link( $post_id, 'raw' ) ?: '';

			foreach ( $links as $link ) {
				$url = $link['url'];

				if ( '' === $url || preg_match( '~^(mailto:|tel:|javascript:|data:|#)~i', $url ) ) {
					continue;
				}
				$abs_url = $this->make_absolute_url( $url, $post_id );
				if ( '' === $abs_url ) {
					continue;
				}
				if ( $this->is_url_ignored( $abs_url, $ignored_set ) ) {
					continue;
				}

				if ( $is_https_site && 0 === strpos( $abs_url, 'http://' ) ) {
					$broken_rows[] = [
						'post_id'    => $post_id,
						'post_title' => $post_title,
						'edit_link'  => $edit_link,
						'url'        => $abs_url,
						'anchor'     => $link['anchor'],
						'status'     => 'mixed_content',
						'code'       => 0,
						'chain'      => [],
					];
					++$links_checked;
					continue;
				}

				if ( isset( $url_cache[ $abs_url ] ) ) {
					$check = $url_cache[ $abs_url ];
				} else {
					$check                 = $this->check_url_with_cache( $abs_url, $timeout );
					$url_cache[ $abs_url ] = $check;
				}
				++$links_checked;

				if ( 'ok' === $check['status'] ) {
					continue;
				}

				$row = [
					'post_id'    => $post_id,
					'post_title' => $post_title,
					'edit_link'  => $edit_link,
					'url'        => $abs_url,
					'anchor'     => $link['anchor'],
					'status'     => $check['status'],
					'code'       => (int) $check['code'],
					'chain'      => array_values( (array) ( $check['chain'] ?? [] ) ),
					'issues'     => array_values( (array) ( $check['issues'] ?? [] ) ),
				];
				if ( 'redirect_chain' === $check['status'] ) {
					$redirect_rows[] = $row;
				} else {
					$broken_rows[] = $row;
				}
			}
		}

		$job['cursor']        = $cursor;
		$job['links_checked'] = $links_checked;
		$job['broken']        = $broken_rows;
		$job['redirects']     = $redirect_rows;
		return $job;
	}

	/**
	 * Build the final results payload from completed job state, persist it,
	 * and return it.
	 *
	 * @param array $job Completed scan job state.
	 * @return array
	 */
	private function finalize_scan_job( array $job ): array {
		$broken    = (array) $job['broken'];
		$redirects = (array) $job['redirects'];
		$mixed     = array_reduce(
			$broken,
			fn( $c, $r ) => $c + ( 'mixed_content' === ( $r['status'] ?? '' ) ? 1 : 0 ),
			0
		);

		$result = [
			'scanned_at'    => current_time( 'mysql' ),
			'posts_scanned' => (int) $job['cursor'],
			'total_posts'   => (int) $job['total_posts'],
			'links_checked' => (int) $job['links_checked'],
			'broken'        => array_values( $broken ),
			'redirects'     => array_values( $redirects ),
			'summary'       => [
				'broken'        => count( $broken ),
				'redirects'     => count( $redirects ),
				'mixed_content' => (int) $mixed,
			],
			'host'          => (string) wp_parse_url( home_url(), PHP_URL_HOST ),
			'enum_capped'   => (int) $job['total_posts'] >= self::MAX_POSTS_ENUM,
		];
		update_option( self::RESULTS_OPTION, $result, false );
		return $result;
	}

	/**
	 * Reduce job state to a compact progress payload safe to send to the UI.
	 * Excludes the post_ids array (potentially thousands of integers).
	 *
	 * @param array $job  Scan job state.
	 * @param bool  $done Whether the job is finished.
	 * @return array
	 */
	private function job_to_progress( array $job, bool $done ): array {
		$broken    = (array) ( $job['broken'] ?? [] );
		$redirects = (array) ( $job['redirects'] ?? [] );
		$mixed     = array_reduce(
			$broken,
			fn( $c, $r ) => $c + ( 'mixed_content' === ( $r['status'] ?? '' ) ? 1 : 0 ),
			0
		);
		return [
			'job_id'        => (string) ( $job['job_id'] ?? '' ),
			'source'        => (string) ( $job['source'] ?? 'interactive' ),
			'cursor'        => (int) ( $job['cursor'] ?? 0 ),
			'total_posts'   => (int) ( $job['total_posts'] ?? 0 ),
			'links_checked' => (int) ( $job['links_checked'] ?? 0 ),
			'summary'       => [
				'broken'        => count( $broken ),
				'redirects'     => count( $redirects ),
				'mixed_content' => (int) $mixed,
			],
			'done'          => (bool) $done,
		];
	}

	/**
	 * If the schedule has email alerts enabled and new issues appeared in
	 * this scan, send a digest email to the configured recipient (defaults
	 * to the site admin).
	 *
	 * @param mixed $previous Previous results array, or anything else (will be coerced).
	 * @param array $current  Newly finalized results payload.
	 */
	private function maybe_send_alert_email( $previous, array $current ): void {
		$schedule = (array) get_option( self::SCHEDULE_OPTION, [] );
		if ( empty( $schedule['email_alerts'] ) ) {
			return;
		}

		$prev_keys = [];
		if ( is_array( $previous ) ) {
			foreach ( array_merge( (array) ( $previous['broken'] ?? [] ), (array) ( $previous['redirects'] ?? [] ) ) as $r ) {
				$prev_keys[ (int) ( $r['post_id'] ?? 0 ) . '|' . (string) ( $r['url'] ?? '' ) ] = true;
			}
		}

		$new_rows = [];
		foreach ( array_merge( (array) $current['broken'], (array) $current['redirects'] ) as $r ) {
			$key = (int) ( $r['post_id'] ?? 0 ) . '|' . (string) ( $r['url'] ?? '' );
			if ( ! isset( $prev_keys[ $key ] ) ) {
				$new_rows[] = $r;
			}
		}
		if ( empty( $new_rows ) ) {
			return;
		}

		$recipient = ! empty( $schedule['email_recipient'] ) && is_email( $schedule['email_recipient'] )
			? $schedule['email_recipient']
			: (string) get_option( 'admin_email' );
		if ( '' === $recipient ) {
			return;
		}

		$site_name = wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES );
		$subject   = sprintf(
			/* translators: 1: site name, 2: count of new issues */
			__( '[%1$s] %2$d new broken link(s) detected', 'antimanual' ),
			$site_name,
			count( $new_rows )
		);

		$lines = [
			sprintf(
				/* translators: %d: number of new issues */
				__( '%d new broken-link issue(s) were detected in your latest scheduled scan:', 'antimanual' ),
				count( $new_rows )
			),
			'',
		];
		foreach ( array_slice( $new_rows, 0, 50 ) as $r ) {
			$status = (string) ( $r['status'] ?? '' );
			$code   = (int) ( $r['code'] ?? 0 );
			$lines[] = sprintf(
				'- [%s%s] %s  —  %s',
				$status,
				$code > 0 ? ' ' . $code : '',
				(string) ( $r['url'] ?? '' ),
				(string) ( $r['post_title'] ?? '' )
			);
		}
		if ( count( $new_rows ) > 50 ) {
			$lines[] = '';
			$lines[] = sprintf(
				/* translators: %d: count of additional issues */
				__( '...and %d more.', 'antimanual' ),
				count( $new_rows ) - 50
			);
		}
		$lines[] = '';
		$lines[] = __( 'Open the Broken Link Checker to review and fix:', 'antimanual' );
		$lines[] = admin_url( 'admin.php?page=atml-broken-links' );

		wp_mail( $recipient, $subject, implode( "\n", $lines ) );
	}

	/**
	 * Extract links and anchor text from post HTML.
	 *
	 * @param string $content Post content HTML.
	 * @return array<int, array{url:string, anchor:string}>
	 */
	private function extract_links_from_content( string $content ): array {
		$links = [];
		// Require whitespace before `href=` so we don't match `data-href`, `aria-href`, etc.
		if ( ! preg_match_all( '/<a\b[^>]*?\shref\s*=\s*["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', $content, $matches ) ) {
			return $links;
		}
		$seen = [];
		foreach ( $matches[1] as $idx => $href ) {
			$href = trim( $href );
			if ( '' === $href || isset( $seen[ $href ] ) ) {
				continue;
			}
			$seen[ $href ] = true;
			$anchor = trim( wp_strip_all_tags( (string) ( $matches[2][ $idx ] ?? '' ) ) );
			if ( strlen( $anchor ) > 120 ) {
				$anchor = substr( $anchor, 0, 117 ) . '…';
			}
			$links[] = [ 'url' => $href, 'anchor' => $anchor ];
		}
		return $links;
	}

	/**
	 * Resolve a relative/protocol-relative URL to an absolute URL.
	 *
	 * @param string $url     Raw href value.
	 * @param int    $post_id Origin post ID (used for base URL).
	 * @return string Absolute URL, or empty string if unresolvable.
	 */
	private function make_absolute_url( string $url, int $post_id ): string {
		if ( preg_match( '/^https?:\/\//i', $url ) ) {
			return $url;
		}
		if ( 0 === strpos( $url, '//' ) ) {
			$scheme = (string) wp_parse_url( home_url(), PHP_URL_SCHEME );
			return $scheme . ':' . $url;
		}
		$base = get_permalink( $post_id );
		if ( ! $base ) {
			return '';
		}
		$base_parts = wp_parse_url( $base );
		if ( ! is_array( $base_parts ) ) {
			return '';
		}
		$scheme = $base_parts['scheme'] ?? 'https';
		$host   = $base_parts['host'] ?? '';
		if ( '' === $host ) {
			return '';
		}
		if ( 0 === strpos( $url, '/' ) ) {
			return $scheme . '://' . $host . $url;
		}
		// Relative path — resolve against post URL directory.
		$path = $base_parts['path'] ?? '/';
		$dir  = rtrim( dirname( $path ), '/' ) . '/';
		return $scheme . '://' . $host . $dir . $url;
	}

	/**
	 * Wraps {@see check_url()} with a transient-backed cache so a URL the previous scan
	 * already verified is not re-fetched on the next scan within the TTL window.
	 *
	 * @param string $url     URL to check.
	 * @param int    $timeout HTTP timeout seconds.
	 * @return array{status:string, code:int, chain:array<int,array{url:string,code:int|string}>, issues:array<int,string>}
	 */
	private function check_url_with_cache( string $url, int $timeout ): array {
		$key    = self::URL_CACHE_PREFIX . md5( $url );
		$cached = get_transient( $key );
		if ( is_array( $cached ) && isset( $cached['status'], $cached['code'] ) ) {
			return $cached;
		}
		$result = $this->check_url( $url, $timeout );
		// Don't cache transient failures (timeouts) — they're often flakes worth re-checking soon.
		if ( 'timeout' !== $result['status'] ) {
			set_transient( $key, $result, self::URL_CACHE_TTL );
		}
		return $result;
	}

	/**
	 * Check a URL by manually tracing each redirect hop.
	 *
	 * Mirrors redirect_chain_checker.py (trace_redirects + analyze_chain):
	 * - Loop detection: same URL seen twice → "Redirect loop detected".
	 * - Mixed HTTP/HTTPS: protocol changes mid-chain.
	 * - Temporary redirects: 302/307 that should probably be 301.
	 * - Chain ending in error: final hop is 4xx/5xx.
	 * - Full chain stored as [{url, code}] for visualization.
	 * - Redirect chain threshold raised to 3 hops (matching Python script).
	 *
	 * @param string $url     URL to check.
	 * @param int    $timeout HTTP timeout seconds.
	 * @return array{status:string, code:int, chain:array<int,array{url:string,code:int}>, issues:array<string>}
	 */
	private function check_url( string $url, int $timeout ): array {
		$chain         = [];
		$seen          = [];
		$issues        = [];
		$current       = $url;
		$max_redirects = 10; // matches redirect_chain_checker.py max_hops default
		$code          = 0;

		$original_scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );

		for ( $i = 0; $i <= $max_redirects; $i++ ) {
			// Loop detection (redirect_chain_checker.py: "if current in seen").
			if ( isset( $seen[ $current ] ) ) {
				$issues[] = __( 'Redirect loop detected', 'antimanual' );
				$chain[]  = [ 'url' => $current, 'code' => 'LOOP' ];
				break;
			}
			$seen[ $current ] = true;

			$response = wp_remote_head( $current, [
				'timeout'     => $timeout,
				'redirection' => 0,
				'user-agent'  => 'Mozilla/5.0 (compatible; AntimanualLinkChecker/1.0)',
			] );

			if ( is_wp_error( $response ) ) {
				// Retry once with GET — some servers reject HEAD.
				$response = wp_remote_get( $current, [
					'timeout'     => $timeout,
					'redirection' => 0,
					'user-agent'  => 'Mozilla/5.0 (compatible; AntimanualLinkChecker/1.0)',
				] );
				if ( is_wp_error( $response ) ) {
					return [ 'status' => 'timeout', 'code' => 0, 'chain' => $chain, 'issues' => $issues ];
				}
			}

			$code     = (int) wp_remote_retrieve_response_code( $response );
			$location = (string) wp_remote_retrieve_header( $response, 'location' );

			// 405/403 on HEAD → retry GET.
			if ( in_array( $code, [ 403, 405 ], true ) ) {
				$get = wp_remote_get( $current, [
					'timeout'     => $timeout,
					'redirection' => 0,
					'user-agent'  => 'Mozilla/5.0 (compatible; AntimanualLinkChecker/1.0)',
				] );
				if ( ! is_wp_error( $get ) ) {
					$code     = (int) wp_remote_retrieve_response_code( $get );
					$location = (string) wp_remote_retrieve_header( $get, 'location' );
				}
			}

			if ( $code >= 300 && $code < 400 && '' !== $location ) {
				// Mixed HTTP/HTTPS detection (redirect_chain_checker.py).
				$current_scheme = strtolower( (string) wp_parse_url( $current, PHP_URL_SCHEME ) );
				if ( $current_scheme !== $original_scheme ) {
					$issues[] = __( 'Mixed HTTP/HTTPS in chain', 'antimanual' );
				}

				// Temporary redirect detection (redirect_chain_checker.py).
				if ( in_array( $code, [ 302, 307 ], true ) ) {
					$issues[] = sprintf(
						/* translators: %d: HTTP status code */
						__( '%d temporary redirect — consider using 301', 'antimanual' ),
						$code
					);
				}

				$location_abs = preg_match( '/^https?:\/\//i', $location )
					? $location
					: $this->resolve_relative_to( $current, $location );

				$chain[] = [ 'url' => $current, 'code' => $code ];
				$current = $location_abs;
				continue;
			}

			// Record final hop.
			$chain[] = [ 'url' => $current, 'code' => $code ];
			break;
		}

		// Chain ending in error (redirect_chain_checker.py analyze_chain).
		if ( $code >= 400 ) {
			/* translators: %d: HTTP status code */
			$issues[] = sprintf( __( 'Chain ends in %d error', 'antimanual' ), $code );
		}

		$issues    = array_values( array_unique( $issues ) );
		$hop_count = count( $chain ) - 1; // hops = steps minus the final destination

		// Determine if there are serious issues that warrant flagging even short chains.
		// Loop detection and mixed content are serious; temporary redirect warnings are not.
		$serious_issues = array_filter(
			$issues,
			fn( $issue ) => (
				false !== strpos( $issue, 'Redirect loop detected' )
				|| false !== strpos( $issue, 'Mixed HTTP/HTTPS' )
			)
		);

		if ( 0 === $code ) {
			return [ 'status' => 'timeout',       'code' => 0,     'chain' => $chain, 'issues' => $issues ];
		}
		if ( $code >= 400 && $code < 500 ) {
			return [ 'status' => 'not_found',     'code' => $code, 'chain' => $chain, 'issues' => $issues ];
		}
		if ( $code >= 500 ) {
			return [ 'status' => 'server_error',  'code' => $code, 'chain' => $chain, 'issues' => $issues ];
		}
		// Flag as redirect_chain if 3+ hops OR if there are serious issues (loop/mixed content).
		// Temporary redirect warnings alone don't warrant flagging short chains.
		if ( $hop_count >= 3 || ! empty( $serious_issues ) ) {
			return [ 'status' => 'redirect_chain', 'code' => $code, 'chain' => $chain, 'issues' => $issues ];
		}
		return [ 'status' => 'ok', 'code' => $code, 'chain' => $chain, 'issues' => $issues ];
	}

	/**
	 * Resolve a relative redirect Location header against a source URL.
	 *
	 * @param string $source   Source URL.
	 * @param string $relative Relative location.
	 * @return string
	 */
	private function resolve_relative_to( string $source, string $relative ): string {
		$parts = wp_parse_url( $source );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return $relative;
		}
		$scheme = $parts['scheme'] ?? 'https';
		$host   = $parts['host'];
		if ( 0 === strpos( $relative, '/' ) ) {
			return $scheme . '://' . $host . $relative;
		}
		$path = $parts['path'] ?? '/';
		$dir  = rtrim( dirname( $path ), '/' ) . '/';
		return $scheme . '://' . $host . $dir . $relative;
	}

	/**
	 * Normalize a URL for consistent comparison.
	 *
	 * Converts http:// to https:// when the current site uses HTTPS,
	 * ensuring URLs with different protocol schemes are treated as
	 * equivalent for comparison purposes.
	 *
	 * @param string $url The URL to normalize.
	 * @return string Normalized URL.
	 */
	private function normalize_url_for_comparison( string $url ): string {
		$parsed = wp_parse_url( $url );
		if ( ! is_array( $parsed ) || empty( $parsed['host'] ) ) {
			return $url;
		}
		$scheme = $parsed['scheme'] ?? 'https';
		if ( $this->is_https_site && 'http' === strtolower( $scheme ) ) {
			$scheme = 'https';
		}
		$host = $parsed['host'];
		$port = ! empty( $parsed['port'] ) ? ':' . $parsed['port'] : '';
		$path = $parsed['path'] ?? '';
		$query = isset( $parsed['query'] ) ? '?' . $parsed['query'] : '';
		$fragment = isset( $parsed['fragment'] ) ? '#' . $parsed['fragment'] : '';
		return $scheme . '://' . $host . $port . $path . $query . $fragment;
	}

	/**
	 * Check if URL should be skipped based on the ignore list.
	 *
	 * @param string $abs_url    Absolute URL to check.
	 * @param array  $ignored_set Set of ignored URLs for O(1) lookup.
	 * @return bool True if URL should be skipped.
	 */
	private function is_url_ignored( string $abs_url, array $ignored_set ): bool {
		if ( empty( $ignored_set ) ) {
			return false;
		}
		if ( isset( $ignored_set[ $abs_url ] ) ) {
			return true;
		}
		$normalized = $this->normalize_url_for_comparison( $abs_url );
		if ( $normalized !== $abs_url && isset( $ignored_set[ $normalized ] ) ) {
			return true;
		}

		$abs_host = strtolower( (string) wp_parse_url( $abs_url, PHP_URL_HOST ) );
		foreach ( array_keys( $ignored_set ) as $ignored_url ) {
			$ignored_url = trim( $ignored_url );
			if ( '' === $ignored_url ) {
				continue;
			}

			// Direct host check
			$ignored_host = strtolower( (string) wp_parse_url( $ignored_url, PHP_URL_HOST ) );
			if ( '' === $ignored_host ) {
				// No scheme in ignored url, treat it as a domain name (e.g. "example.com")
				$ignored_host = strtolower( $ignored_url );
			}

			if ( '' !== $ignored_host ) {
				if ( $abs_host === $ignored_host 
					|| 0 === strpos( $abs_host, $ignored_host . '.' ) 
					|| 0 === strpos( $abs_host, 'www.' . $ignored_host ) 
					|| ( strlen( $abs_host ) > strlen( $ignored_host ) && 0 === substr_compare( $abs_host, '.' . $ignored_host, -strlen( $ignored_host ) - 1 ) ) 
				) {
					return true;
				}
			}

			// Prefix match
			if ( 0 === strpos( $abs_url, $ignored_url ) ) {
				return true;
			}
			if ( $normalized !== $abs_url && 0 === strpos( $normalized, $ignored_url ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Remove a fixed link from the cached results so the UI reflects the fix.
	 *
	 * @param int    $post_id Post whose link was fixed.
	 * @param string $old_url The URL that was removed/updated.
	 */
	private function remove_link_from_results_cache( int $post_id, string $old_url ): void {
		$data = get_option( self::RESULTS_OPTION, null );
		if ( ! is_array( $data ) ) {
			return;
		}
		$filter = function ( $rows ) use ( $post_id, $old_url ) {
			return array_values( array_filter( (array) $rows, function ( $r ) use ( $post_id, $old_url ) {
				return ! ( (int) ( $r['post_id'] ?? 0 ) === $post_id && (string) ( $r['url'] ?? '' ) === $old_url );
			} ) );
		};
		$data['broken']    = $filter( $data['broken'] ?? [] );
		$data['redirects'] = $filter( $data['redirects'] ?? [] );
		$data['summary']   = [
			'broken'        => count( $data['broken'] ),
			'redirects'     => count( $data['redirects'] ),
			'mixed_content' => count( array_filter( $data['broken'], fn( $r ) => 'mixed-content' === ( $r['status'] ?? '' ) ) ),
		];
		update_option( self::RESULTS_OPTION, $data, false );
	}
}
