<?php

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

/**
 * Classifies pages that should remain inventoried but not affect site scoring.
 */
class Content_Miner_Scoring_Exclusions {
	const OVERRIDE_META_KEY = '_content_miner_scoring_override';
	const SITEMAP_OPTION    = 'content_miner_scoring_sitemap';
	const SITEMAP_TIMEOUT   = 10;
	const MAX_SITEMAP_BYTES = 5 * 1024 * 1024;
	const MAX_SITEMAP_URLS  = 50000;

	/** @var callable|null */
	private $fetcher;

	/** @var array<string,mixed>|null */
	private $sitemap_snapshot;

	/**
	 * @param callable|null $fetcher Optional test seam for the one sitemap request.
	 */
	public function __construct( $fetcher = null ) {
		$this->fetcher          = is_callable( $fetcher ) ? $fetcher : null;
		$this->sitemap_snapshot = null;
	}

	/**
	 * Refresh the complete sitemap URL set once at synchronization start.
	 *
	 * A sitemap index is not a page URL set. Following its child documents would
	 * exceed the one-request synchronization budget, so indexes fail toward
	 * inclusion just like unavailable or malformed sitemaps.
	 *
	 * @return array{changed:bool,snapshot:array<string,mixed>}
	 */
	public function refresh_sitemap() {
		$previous = get_option( self::SITEMAP_OPTION, array() );
		$previous = is_array( $previous ) ? $previous : array();
		$captured_at = gmdate( 'c' );
		$url          = $this->discover_sitemap_url();
		$snapshot     = $this->fetch_sitemap( $url );

		// Use request start as the cutoff: content changed during the fetch fails toward inclusion.
		$snapshot['captured_at'] = $captured_at;
		$snapshot['fingerprint'] = self::snapshot_fingerprint( $snapshot );
		$this->sitemap_snapshot  = $snapshot;
		update_option( self::SITEMAP_OPTION, $snapshot, false );

		$fingerprint_changed = ! isset( $previous['fingerprint'] ) || ! hash_equals( (string) $previous['fingerprint'], $snapshot['fingerprint'] );

		return array(
			'changed'  => $fingerprint_changed,
			'snapshot' => $snapshot,
		);
	}

	/**
	 * Return all automatic reasons and the effective operator-controlled result.
	 *
	 * @param WP_Post|object $post WordPress post.
	 * @return array<string,mixed>
	 */
	public function classify( $post ) {
		$reasons = array();
		if ( $this->matches_legal_or_utility_pattern( $post ) ) {
			$reasons[] = 'legal_utility';
		}
		if ( $this->matches_wordpress_default_content( $post ) ) {
			$reasons[] = 'wp_default';
		}
		if ( $this->is_noindex( (int) $post->ID ) ) {
			$reasons[] = 'noindex';
		}
		if ( $this->is_absent_from_sitemap( $post ) ) {
			$reasons[] = 'sitemap_absent';
		}

		$override = get_post_meta( (int) $post->ID, self::OVERRIDE_META_KEY, true );
		$override = is_scalar( $override ) && 'include' === (string) $override ? 'include' : '';
		$excluded = ! $override && ! empty( $reasons );

		return array(
			'excluded'         => $excluded,
			'scored'           => ! $excluded,
			'automatic_reasons' => $reasons,
			'primary_reason'   => isset( $reasons[0] ) ? $reasons[0] : '',
			'override'         => $override,
		);
	}

	/**
	 * Persist or remove the one supported operator override.
	 *
	 * @param int    $post_id WordPress post ID.
	 * @param string $mode include or automatic.
	 * @return bool
	 */
	public function set_override( $post_id, $mode ) {
		$post_id = absint( $post_id );
		if ( ! $post_id ) {
			return false;
		}
		if ( 'include' === $mode ) {
			$current = get_post_meta( $post_id, self::OVERRIDE_META_KEY, true );
			if ( is_scalar( $current ) && 'include' === (string) $current ) {
				return true;
			}
			return false !== update_post_meta( $post_id, self::OVERRIDE_META_KEY, 'include' );
		}
		if ( 'automatic' === $mode ) {
			$current = get_post_meta( $post_id, self::OVERRIDE_META_KEY, true );
			if ( '' === $current ) {
				return true;
			}
			delete_post_meta( $post_id, self::OVERRIDE_META_KEY );
			return '' === get_post_meta( $post_id, self::OVERRIDE_META_KEY, true );
		}
		return false;
	}

	/**
	 * Return whether an item or remembered row remains in scope for scored report consumers.
	 *
	 * @param array<string,mixed> $item Item or memory.
	 * @return bool
	 */
	public static function is_in_scope( array $item ) {
		return ! isset( $item['scoring'] )
			|| ! is_array( $item['scoring'] )
			|| ! array_key_exists( 'excluded', $item['scoring'] )
			|| true !== $item['scoring']['excluded'];
	}

	/**
	 * Back-compat alias for the shared report-scope decision.
	 *
	 * @param array<string,mixed> $item Item or memory.
	 * @return bool
	 */
	public static function is_scored( array $item ) {
		return self::is_in_scope( $item );
	}

	/**
	 * English-only, exact normalized slugs and titles.
	 *
	 * @return string[]
	 */
	public static function legal_utility_patterns() {
		return array(
			'privacy-policy',
			'privacy-notice',
			'privacy-statement',
			'terms',
			'terms-and-conditions',
			'terms-conditions',
			'terms-of-service',
			'terms-of-use',
			'cookie-policy',
			'cookie-notice',
			'cookie-settings',
			'cookie-preferences',
			'data-collection',
			'data-policy',
			'data-protection-policy',
			'gdpr',
			'general-data-protection-regulation',
			'ccpa',
			'california-consumer-privacy-act',
			'do-not-sell',
			'do-not-sell-my-personal-information',
			'privacy-choices',
			'privacy-center',
			'privacy-centre',
			'legal',
			'legal-notice',
			'legal-disclaimer',
			'imprint',
			'disclaimer',
			'accessibility',
			'accessibility-statement',
			'refund-policy',
			'return-policy',
			'returns',
			'shipping-policy',
			'thank-you',
			'thanks',
			'subscribe',
			'unsubscribe',
			'search',
			'404',
			'cart',
			'shopping-cart',
			'basket',
			'checkout',
			'order-received',
			'account',
			'my-account',
			'login',
			'log-in',
			'sign-in',
			'lost-password',
			'password-reset',
		);
	}

	/**
	 * Labels shown next to an inventoried page.
	 *
	 * @return array<string,string>
	 */
	public static function reason_labels() {
		return array(
			'legal_utility'  => __( 'Legal or utility page', 'content-miner' ),
			'wp_default'     => __( 'Unedited WordPress default content', 'content-miner' ),
			'noindex'        => __( 'Marked noindex in WordPress', 'content-miner' ),
			'sitemap_absent' => __( 'Not listed in the site sitemap', 'content-miner' ),
		);
	}

	/**
	 * Read noindex from WordPress-owned SEO plugin post metadata.
	 *
	 * @param int $post_id WordPress post ID.
	 * @return bool
	 */
	public function is_noindex( $post_id ) {
		$directives = array();
		$yoast      = get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true );
		if ( is_scalar( $yoast ) ) {
			if ( '1' === trim( (string) $yoast ) ) {
				$directives[] = 'noindex';
			} elseif ( '2' === trim( (string) $yoast ) ) {
				$directives[] = 'index';
			}
		}

		$rank_math = get_post_meta( $post_id, 'rank_math_robots', true );
		if ( '' !== $rank_math && null !== $rank_math && false !== $rank_math ) {
			if ( ! is_array( $rank_math ) || ( ! empty( $rank_math ) && array_keys( $rank_math ) !== range( 0, count( $rank_math ) - 1 ) ) ) {
				// A malformed Rank Math shape is not reliable exclusion evidence.
				return false;
			}
			foreach ( $rank_math as $directive ) {
				if ( ! is_scalar( $directive ) ) {
					return false;
				}
				$directive = strtolower( trim( (string) $directive ) );
				if ( in_array( $directive, array( 'index', 'noindex' ), true ) ) {
					$directives[] = $directive;
				}
			}
		}

		$aioseo = get_post_meta( $post_id, '_aioseo_robots_noindex', true );
		if ( '1' === $aioseo ) {
			$directives[] = 'noindex';
		}

		$directives = array_values( array_unique( $directives ) );
		if ( in_array( 'index', $directives, true ) && in_array( 'noindex', $directives, true ) ) {
			return false;
		}
		return in_array( 'noindex', $directives, true );
	}

	/**
	 * Normalize a same-origin URL for stable sitemap membership.
	 *
	 * @param string $url URL.
	 * @return string
	 */
	public static function normalize_url( $url ) {
		$parts = wp_parse_url( trim( html_entity_decode( (string) $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) );
		if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {
			return '';
		}

		$host   = strtolower( rtrim( (string) $parts['host'], '.' ) );
		$scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
		$port   = isset( $parts['port'] ) ? (int) $parts['port'] : 0;
		if ( ( 'http' === $scheme && 80 === $port ) || ( 'https' === $scheme && 443 === $port ) ) {
			$port = 0;
		}
		$port  = $port ? ':' . $port : '';
		$path  = isset( $parts['path'] ) && '' !== $parts['path'] ? (string) $parts['path'] : '/';
		$path  = '/' . ltrim( preg_replace( '#/+#', '/', $path ), '/' );
		if ( '/' !== $path ) {
			$path = rtrim( $path, '/' );
		}
		$query = isset( $parts['query'] ) && '' !== $parts['query'] ? '?' . $parts['query'] : '';

		return $host . $port . $path . $query;
	}

	/**
	 * @param WP_Post|object $post WordPress post.
	 * @return bool
	 */
	private function matches_legal_or_utility_pattern( $post ) {
		$patterns = self::legal_utility_patterns();
		$slug     = self::normalize_pattern_value( isset( $post->post_name ) ? $post->post_name : '' );
		$title    = self::normalize_pattern_value( isset( $post->post_title ) ? $post->post_title : '' );

		return in_array( $slug, $patterns, true )
			|| in_array( $title, $patterns, true )
			|| $this->matches_template_hint( $post );
	}

	/**
	 * Treat only an exact, normalized page-template basename as evidence.
	 * Generic template names and partial phrases deliberately fail toward scoring.
	 *
	 * @param WP_Post|object $post WordPress post.
	 * @return bool
	 */
	private function matches_template_hint( $post ) {
		$template = get_post_meta( (int) $post->ID, '_wp_page_template', true );
		if ( ! is_scalar( $template ) ) {
			return false;
		}
		$template = trim( str_replace( '\\', '/', (string) $template ) );
		if ( '' === $template || 'default' === strtolower( $template ) ) {
			return false;
		}
		$basename = preg_replace( '/\.php$/i', '', basename( $template ) );
		$basename = self::normalize_pattern_value( $basename );
		$basename = preg_replace( '/^(?:page|template)-/', '', $basename );
		$basename = preg_replace( '/-(?:page|template)$/', '', $basename );

		return in_array( $basename, self::template_hint_patterns(), true );
	}

	/** Exact normalized template basenames that are strong boilerplate evidence. @return string[] */
	private static function template_hint_patterns() {
		return array(
			'privacy',
			'privacy-policy',
			'privacy-notice',
			'terms',
			'terms-and-conditions',
			'terms-conditions',
			'terms-of-service',
			'terms-of-use',
			'cookie-policy',
			'cookie-notice',
			'data-collection',
			'data-privacy',
			'data-protection',
			'gdpr',
			'ccpa',
			'legal',
			'legal-notice',
			'legal-disclaimer',
			'disclaimer',
		);
	}

	/**
	 * Match only the known WordPress starter bodies, not slugs alone.
	 *
	 * Slugs often survive a real rewrite, and post-modified dates are unreliable
	 * after imports or restores. Body comparison avoids excluding edited pages
	 * that kept the default URL.
	 *
	 * @param WP_Post|object $post WordPress post.
	 * @return bool
	 */
	private function matches_wordpress_default_content( $post ) {
		$slug = self::normalize_pattern_value( isset( $post->post_name ) ? $post->post_name : '' );
		$title = self::normalize_pattern_value( isset( $post->post_title ) ? $post->post_title : '' );
		$type = in_array( $slug, array( 'hello-world', 'sample-page' ), true ) ? $slug : $title;
		if ( ! in_array( $type, array( 'hello-world', 'sample-page' ), true ) ) {
			return false;
		}
		$content = self::normalize_default_content( isset( $post->post_content ) ? $post->post_content : '' );
		$markers = array(
			'hello-world' => self::normalize_default_content( 'Welcome to WordPress. This is your first post. Edit or delete it, then start writing!' ),
			'sample-page' => self::normalize_default_content( "This is an example page. It's different from a blog post because it will stay in one place and will show up in your site navigation in most themes." ),
		);
		$marker = isset( $markers[ $type ] ) ? $markers[ $type ] : '';
		return '' !== $content
			&& '' !== $marker
			&& 0 === strpos( $content, $marker )
			&& strlen( $content ) <= strlen( $marker ) + 600;
	}

	/**
	 * Apply sitemap absence only to published pages older than a complete snapshot.
	 *
	 * @param WP_Post|object $post WordPress post.
	 * @return bool
	 */
	private function is_absent_from_sitemap( $post ) {
		if ( ! isset( $post->post_status ) || 'publish' !== $post->post_status ) {
			return false;
		}

		$snapshot = $this->get_sitemap_snapshot();
		if ( empty( $snapshot['membership_known'] ) || empty( $snapshot['urls'] ) || ! is_array( $snapshot['urls'] ) ) {
			return false;
		}

		$captured = isset( $snapshot['captured_at'] ) ? strtotime( (string) $snapshot['captured_at'] ) : false;
		$modified = isset( $post->post_modified_gmt ) ? strtotime( (string) $post->post_modified_gmt . ' UTC' ) : false;
		if ( false === $captured || false === $modified || $modified >= $captured ) {
			return false;
		}

		$url = self::normalize_url( get_permalink( $post ) );
		return '' !== $url && empty( $snapshot['urls'][ $url ] );
	}

	/** @return array<string,mixed> */
	private function get_sitemap_snapshot() {
		if ( null === $this->sitemap_snapshot ) {
			$snapshot = get_option( self::SITEMAP_OPTION, array() );
			$this->sitemap_snapshot = is_array( $snapshot ) ? $snapshot : array();
		}
		return $this->sitemap_snapshot;
	}

	/** @return string */
	private function discover_sitemap_url() {
		$filtered = (string) apply_filters( 'content_miner_scoring_sitemap_url', '' );
		if ( self::is_http_url( $filtered ) && self::same_origin( $filtered, home_url( '/' ) ) ) {
			return esc_url_raw( $filtered );
		}
		if ( '' !== trim( $filtered ) ) {
			return '';
		}

		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- robots_txt is a WordPress core filter used for sitemap discovery.
		$robots = (string) apply_filters( 'robots_txt', '', (bool) get_option( 'blog_public', true ) );
		if ( preg_match_all( '/^\s*Sitemap:\s*(\S+)\s*$/mi', $robots, $matches ) ) {
			$candidates = array_values( array_unique( array_map( 'trim', $matches[1] ) ) );
			if ( 1 !== count( $candidates ) ) {
				return '';
			}
			$candidate = $candidates[0];
			return self::is_http_url( $candidate ) && self::same_origin( $candidate, home_url( '/' ) )
				? esc_url_raw( $candidate )
				: '';
		}

		if ( function_exists( 'get_sitemap_url' ) ) {
			$native = get_sitemap_url( 'index' );
			if ( self::is_http_url( $native ) && self::same_origin( $native, home_url( '/' ) ) ) {
				return esc_url_raw( $native );
			}
		}
		return home_url( '/wp-sitemap.xml' );
	}

	/**
	 * @param string $url Sitemap URL.
	 * @return array<string,mixed>
	 */
	private function fetch_sitemap( $url ) {
		$unavailable = array(
			'url'              => (string) $url,
			'membership_known' => false,
			'urls'             => array(),
			'status'           => 'unavailable',
		);
		if ( ! self::is_http_url( $url ) ) {
			$unavailable['reason'] = 'no_sitemap_url';
			return $unavailable;
		}
		if ( ! self::same_origin( $url, home_url( '/' ) ) ) {
			$unavailable['reason'] = 'unsafe_sitemap_url';
			return $unavailable;
		}

		$args = array(
			'timeout'             => self::SITEMAP_TIMEOUT,
			'redirection'         => 0,
			'limit_response_size' => self::MAX_SITEMAP_BYTES + 1,
			'sslverify'           => true,
			'cookies'             => array(),
			'headers'             => array( 'Accept' => 'application/xml,text/xml;q=0.9,*/*;q=0.1' ),
		);
		$response = $this->fetcher
			? call_user_func( $this->fetcher, $url, $args )
			: wp_safe_remote_get( $url, $args );
		if ( is_wp_error( $response ) ) {
			$unavailable['reason'] = 'fetch_failed';
			return $unavailable;
		}
		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			$unavailable['reason'] = 'http_error';
			return $unavailable;
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === trim( $body ) || strlen( $body ) > self::MAX_SITEMAP_BYTES ) {
			$unavailable['reason'] = 'empty_or_truncated';
			return $unavailable;
		}

		$parsed = self::parse_urlset( $body, home_url( '/' ) );
		if ( empty( $parsed['membership_known'] ) ) {
			return array_merge( $unavailable, $parsed );
		}

		return array(
			'url'              => (string) $url,
			'membership_known' => true,
			'urls'             => $parsed['urls'],
			'status'           => 'available',
			'reason'           => '',
		);
	}

	/**
	 * Parse only a complete URL set. Sitemap indexes deliberately remain unknown.
	 *
	 * @param string $body     XML body.
	 * @param string $site_url Site origin.
	 * @return array<string,mixed>
	 */
	private static function parse_urlset( $body, $site_url ) {
		if ( false !== stripos( $body, '<!DOCTYPE' ) || false !== stripos( $body, '<!ENTITY' ) ) {
			return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'unsafe_xml' );
		}
		if ( ! class_exists( 'DOMDocument' ) ) {
			return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'xml_parser_unavailable' );
		}

		$previous_errors = libxml_use_internal_errors( true );
		$document        = new DOMDocument();
		$loaded          = $document->loadXML( (string) $body, LIBXML_NONET | LIBXML_NOBLANKS );
		libxml_clear_errors();
		libxml_use_internal_errors( $previous_errors );
		if ( ! $loaded || ! $document->documentElement ) {
			return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'invalid_xml' );
		}

		$root = $document->documentElement;
		if ( 'sitemapindex' === strtolower( (string) $root->localName ) ) {
			return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'sitemap_index' );
		}
		if ( 'urlset' !== strtolower( (string) $root->localName ) ) {
			return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'invalid_urlset' );
		}

		$locations = array();
		foreach ( $root->childNodes as $url_node ) {
			if ( XML_ELEMENT_NODE !== $url_node->nodeType ) {
				continue;
			}
			if ( 'url' !== strtolower( (string) $url_node->localName ) ) {
				return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'invalid_urlset' );
			}
			$entry_locations = array();
			foreach ( $url_node->childNodes as $field ) {
				if ( XML_ELEMENT_NODE === $field->nodeType && 'loc' === strtolower( (string) $field->localName ) ) {
					$entry_locations[] = trim( (string) $field->textContent );
				}
			}
			if ( 1 !== count( $entry_locations ) || '' === $entry_locations[0] ) {
				return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'invalid_url_entry' );
			}
			$locations[] = $entry_locations[0];
			if ( count( $locations ) > self::MAX_SITEMAP_URLS ) {
				return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'too_many_urls' );
			}
		}

		$urls = array();
		foreach ( $locations as $raw_url ) {
			if ( ! self::is_http_url( $raw_url ) || ! self::same_origin( $raw_url, $site_url ) ) {
				return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'invalid_url_origin' );
			}
			$normalized          = self::normalize_url( $raw_url );
			$urls[ $normalized ] = true;
		}
		if ( empty( $urls ) ) {
			return array( 'membership_known' => false, 'urls' => array(), 'reason' => 'empty_urlset' );
		}
		ksort( $urls );
		return array( 'membership_known' => true, 'urls' => $urls, 'reason' => '' );
	}

	/** @param array<string,mixed> $snapshot Snapshot. @return string */
	private static function snapshot_fingerprint( array $snapshot ) {
		$urls = ! empty( $snapshot['membership_known'] ) && isset( $snapshot['urls'] ) && is_array( $snapshot['urls'] )
			? array_keys( $snapshot['urls'] )
			: array();
		sort( $urls );
		return hash( 'sha256', wp_json_encode( array( 'known' => ! empty( $snapshot['membership_known'] ), 'urls' => $urls ) ) );
	}

	/** @param string $value Pattern value. @return string */
	private static function normalize_pattern_value( $value ) {
		$value = strtolower( html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
		$value = preg_replace( '/[^a-z0-9]+/', '-', $value );
		return trim( (string) $value, '-' );
	}

	/** @param string $value Content body. @return string */
	private static function normalize_default_content( $value ) {
		$value = strtolower( html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
		return trim( preg_replace( '/\s+/', ' ', $value ) );
	}

	/** @param string $url URL. @return bool */
	private static function is_http_url( $url ) {
		$parts = wp_parse_url( trim( (string) $url ) );
		if ( ! is_array( $parts ) || ! empty( $parts['user'] ) || ! empty( $parts['pass'] ) ) {
			return false;
		}
		$scheme = isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : '';
		return in_array( $scheme, array( 'http', 'https' ), true ) && ! empty( $parts['host'] );
	}

	/** @param string $left Left URL. @param string $right Right URL. @return bool */
	private static function same_origin( $left, $right ) {
		$left  = wp_parse_url( (string) $left );
		$right = wp_parse_url( (string) $right );
		if ( ! is_array( $left ) || ! is_array( $right ) ) {
			return false;
		}
		$left_scheme  = strtolower( isset( $left['scheme'] ) ? (string) $left['scheme'] : '' );
		$right_scheme = strtolower( isset( $right['scheme'] ) ? (string) $right['scheme'] : '' );
		$left_host    = strtolower( rtrim( isset( $left['host'] ) ? (string) $left['host'] : '', '.' ) );
		$right_host   = strtolower( rtrim( isset( $right['host'] ) ? (string) $right['host'] : '', '.' ) );
		$left_port    = isset( $left['port'] ) ? (int) $left['port'] : ( 'https' === $left_scheme ? 443 : 80 );
		$right_port   = isset( $right['port'] ) ? (int) $right['port'] : ( 'https' === $right_scheme ? 443 : 80 );

		return $left_scheme === $right_scheme && $left_host === $right_host && $left_port === $right_port;
	}
}
