<?php
/**
 * Site checks (free): sitewide defect checks that ride the Site row alongside
 * the AI access evaluation. First resident: the FAVICON audit - SSAA is the
 * cross-plugin OWNER of the favicon_missing check since 2026-07-24
 * (`_standards/docs/DECISIONS.md` "favicon_missing ownership flips to SSAA"):
 * a favicon IS search + social appearance (Google shows favicons in results;
 * chat platforms show the site icon beside link previews). DOIG keeps a
 * courtesy nudge for DOIG-only installs and DEFERS to this check via the
 * shared `plugupp_site_notice_owner_favicon_missing` filter, which this class
 * answers whenever SSAA is active.
 *
 * Detection is the RENDERED truth, like everything in SSAA: the homepage HTML
 * is fetched over the public path and the actual rel=icon links are read -
 * never just the site_icon option. The legacy /favicon.ico fallback is probed
 * before "missing" is claimed (browsers and crawlers still use it).
 *
 * Free per the DEFECTS-free boundary (SPEC-ai-readiness s.4.1 as amended
 * 1.2.3): presence + reachability are defects; size/quality interpretation
 * (Google's >=48px guidance) is a parked premium follow-up (ROADMAP).
 *
 * @package search-social-appearance-audit
 */

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

class SSAA_Site_Checks {

	public static function init() {
		// SSAA owns favicon_missing: siblings' courtesy detectors defer.
		add_filter( 'plugupp_site_notice_owner_favicon_missing', '__return_true' );
	}

	/**
	 * Sitewide search visibility (1.4.0): the "Discourage search engines from
	 * indexing this site" option left switched on. WordPress then emits noindex
	 * on EVERY page, so the whole site is invisible to search - the single most
	 * damaging thing that can happen to a site's search appearance, and a
	 * classic launch-day miss when a site is copied from staging.
	 *
	 * This is the loud half of the noindex rule added in the same release. Once
	 * per-page search findings go quiet on noindexed pages (SSAA_Analyzer's
	 * NOINDEX_MOOT_CODES), a sitewide discourage would otherwise silence the
	 * entire audit with nothing to explain it. So the moment the cause is
	 * sitewide, SSAA stops reporting hundreds of moot page findings and reports
	 * the one fact that actually needs acting on.
	 *
	 * Read from the canonical option rather than inferred from the rendered
	 * pages: blog_public IS the switch, so it cannot drift from what the head
	 * tags say. Free - an unambiguous defect, not an interpretation.
	 *
	 * @return array[] Analyzer-shaped issues (zero or one).
	 */
	public static function search_visibility_issues() {
		if ( '0' !== (string) get_option( 'blog_public' ) ) {
			return array();
		}

		return array(
			array(
				'code'     => 'search_engines_discouraged',
				'area'     => 'search',
				'severity' => 'critical',
				'message'  => __( 'This site is set to discourage search engines, so WordPress adds a noindex tag to every page and none of them can appear in search results. Intentional on a staging or holding site. If this site is live, clear "Discourage search engines from indexing this site" under Settings > Reading. Social sharing is unaffected: link previews still work.', 'search-social-appearance-audit' ),
				'evidence' => array(),
			),
		);
	}

	/**
	 * Audit the rendered homepage's favicon. Returns analyzer-shaped issues
	 * plus the facts for the Site row's panel.
	 *
	 * @return array { issues: array[], facts: array }
	 */
	public static function favicon_audit() {
		$issues = array();
		$facts  = array(
			'declared'  => array(), // rel=icon hrefs found in the rendered homepage.
			'legacy'    => null,    // /favicon.ico reachable (null = not probed).
			'reachable' => null,    // first declared icon reachable.
		);

		$response = wp_remote_get( home_url( '/' ), array(
			'timeout'     => 15,
			'redirection' => 3,
			'user-agent'  => 'SSAA-Audit/' . SSAA_VERSION . '; ' . home_url( '/' ),
		) );

		if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			// Homepage unreadable over the public path - no favicon claim can
			// be made honestly; the render-door scan of the front page reports
			// its own errors. Silent no-op here.
			return array( 'issues' => $issues, 'facts' => $facts );
		}

		$facts['declared'] = self::extract_icon_links( (string) wp_remote_retrieve_body( $response ) );

		if ( empty( $facts['declared'] ) ) {
			// No rel=icon in the rendered head: probe the legacy well-known
			// path before claiming "missing" - browsers and crawlers fall
			// back to it.
			$legacy          = wp_remote_head( home_url( '/favicon.ico' ), array( 'timeout' => 10, 'redirection' => 3 ) );
			$facts['legacy'] = ! is_wp_error( $legacy ) && 200 === (int) wp_remote_retrieve_response_code( $legacy );

			if ( ! $facts['legacy'] ) {
				$issues[] = array(
					'code'     => 'favicon_missing',
					'area'     => 'search',
					'severity' => 'warning',
					'message'  => __( 'The rendered homepage declares no favicon (no rel="icon" link) and /favicon.ico is not reachable. Google shows favicons next to search results and chat platforms show the site icon beside link previews - without one they render a generic placeholder. Add a Site Icon under Appearance > Customize > Site Identity.', 'search-social-appearance-audit' ),
					'evidence' => array(),
				);
			}
		} else {
			// Declared: check the first icon actually resolves (the same
			// reachability standard the og:image check applies).
			$icon_url = $facts['declared'][0]['href'];
			$check    = wp_remote_head( $icon_url, array( 'timeout' => 10, 'redirection' => 3 ) );
			$facts['reachable'] = ! is_wp_error( $check ) && 200 === (int) wp_remote_retrieve_response_code( $check );

			if ( ! $facts['reachable'] ) {
				$issues[] = array(
					'code'     => 'favicon_unreachable',
					'area'     => 'search',
					'severity' => 'warning',
					'message'  => sprintf(
						/* translators: %s: the declared favicon URL */
						__( 'The homepage declares a favicon but its URL does not resolve (%s). Search results and link previews fall back to a generic placeholder when the icon cannot be fetched.', 'search-social-appearance-audit' ),
						$icon_url
					),
					'evidence' => array( $facts['declared'][0]['evidence'] ),
				);
			}
		}

		return array( 'issues' => $issues, 'facts' => $facts );
	}

	/**
	 * rel=icon-family links from rendered homepage HTML, in document order:
	 * icon, shortcut icon, apple-touch-icon (the set Google and the platforms
	 * consume).
	 *
	 * @param string $html Rendered homepage HTML.
	 * @return array[] [ { href, rel, sizes, evidence } ]
	 */
	public static function extract_icon_links( $html ) {
		if ( '' === trim( $html ) ) {
			return array();
		}

		$dom      = new DOMDocument();
		$previous = libxml_use_internal_errors( true );
		$loaded   = $dom->loadHTML( '<?xml encoding="UTF-8">' . $html );
		libxml_clear_errors();
		libxml_use_internal_errors( $previous );
		if ( ! $loaded ) {
			return array();
		}

		$out   = array();
		$xpath = new DOMXPath( $dom );
		foreach ( $xpath->query( '//link[@rel]' ) as $node ) {
			$rel = strtolower( trim( $node->getAttribute( 'rel' ) ) );
			if ( ! in_array( $rel, array( 'icon', 'shortcut icon', 'apple-touch-icon', 'apple-touch-icon-precomposed' ), true ) ) {
				continue;
			}
			$href = trim( $node->getAttribute( 'href' ) );
			if ( '' === $href ) {
				continue;
			}
			$out[] = array(
				'href'     => self::absolutize( $href ),
				'rel'      => $rel,
				'sizes'    => trim( $node->getAttribute( 'sizes' ) ),
				'evidence' => $dom->saveHTML( $node ),
			);
		}
		return $out;
	}

	/**
	 * Resolve a possibly-relative icon href against the site origin.
	 *
	 * @param string $href Raw href.
	 * @return string
	 */
	private static function absolutize( $href ) {
		if ( preg_match( '#^https?://#i', $href ) ) {
			return $href;
		}
		if ( 0 === strpos( $href, '//' ) ) {
			return ( is_ssl() ? 'https:' : 'http:' ) . $href;
		}
		return home_url( '/' === $href[0] ? $href : '/' . $href );
	}
}
