<?php
/**
 * Content discovery: the engine's post/page descriptors plus SSAA's own
 * synthesized ARCHIVE-page descriptors (blog home, taxonomy term archives,
 * monthly date archives). Mirrors LIHA's LIHA_Discovery - the shared
 * cross-plugin pattern (see docs/SPEC-archive-pages.md and LIHA's
 * docs/DESIGN-liha.md s.5) - with one SSAA addition: the free "Archive pages"
 * settings toggle (scan_archives, default on) in front of the code filters.
 *
 * Archive pages ship their own title / meta description / canonical / robots /
 * OG + Twitter tags (usually from the SEO plugin's archive templates), rank in
 * search and get shared - so they are audited like any other page. Everything
 * is computed from the site's own content (get_terms / wp_posts dates); SSAA
 * never crawls or follows links.
 *
 * Descriptor shape (the engine Handler contract's 'url' kind):
 * { kind:'url', ref:'url:'.sha1(url), type:'home'|'tax'|'date', label, permalink }
 *
 * @package search-social-appearance-audit
 */

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

class SSAA_Discovery {

	/**
	 * All page descriptors for a scan: posts/pages from the engine, the
	 * synthesized archive pages, plus the single Site row (AI access +
	 * robots.txt - 1.2.0).
	 *
	 * @return array[]
	 */
	public static function descriptors() {
		return array_merge( SSAA_Engine_Discovery::post_descriptors(), self::synthesized(), self::site_descriptor() );
	}

	/**
	 * The Site row descriptor (1.2.0, SPEC-ai-readiness s.4.3): one url-kind
	 * row of type 'site' carrying the site-level AI-access facts and (premium)
	 * findings. Its permalink is the site's robots.txt - a real, viewable URL
	 * that is also what the row audits - which keeps its ref distinct from the
	 * blog-home archive row (different URL, different sha1). Always on (the
	 * row is excludable like any other); filterable off in code.
	 *
	 * @return array[] Zero or one descriptor.
	 */
	public static function site_descriptor() {
		/**
		 * Toggle the Site (AI access) row.
		 *
		 * @param bool $synthesize Default true.
		 */
		if ( ! apply_filters( 'ssaa_synthesize_site', true ) ) {
			return array();
		}

		$url = home_url( '/robots.txt' );
		return array(
			array(
				'kind'      => 'url',
				'ref'       => SSAA_Engine::url_ref( $url ),
				'type'      => 'site',
				'label'     => __( 'Site checks: robots.txt, AI access, favicon', 'search-social-appearance-audit' ),
				'permalink' => $url,
			),
		);
	}

	/**
	 * Synthesized non-post descriptors: blog home, taxonomy term archives,
	 * monthly date archives. Page 1 only - pagination pages are deliberately
	 * out (LIHA precedent; page 1 carries the canonical appearance metadata).
	 *
	 * Scope control: the free "Archive pages" setting (scan_archives, default
	 * on) is the user-facing switch; the ssaa_synthesize* filters are the
	 * per-kind code-level controls, mirroring LIHA's liha_synthesize* set.
	 *
	 * @return array[]
	 */
	public static function synthesized() {
		if ( ! SSAA_Settings::get( 'scan_archives', true ) ) {
			return array();
		}

		/**
		 * Master switch for synthesized archive descriptors.
		 *
		 * @param bool $synthesize Default true.
		 */
		if ( ! apply_filters( 'ssaa_synthesize', true ) ) {
			return array();
		}

		return self::build_synthesized( false );
	}

	/**
	 * The synthesized set with EVERY gate bypassed (setting, master filter,
	 * per-kind filters). Never used to scan - it exists so display surfaces
	 * (the Exclusions page ref labels) can resolve an excluded archive ref to
	 * a human label even while archive auditing is switched off.
	 *
	 * @return array[]
	 */
	public static function synthesized_raw() {
		return self::build_synthesized( true );
	}

	/**
	 * Build the synthesized descriptor set.
	 *
	 * @param bool $ignore_gates Bypass the per-kind ssaa_synthesize_* filters
	 *                           and the final descriptor filter (label lookups).
	 * @return array[]
	 */
	private static function build_synthesized( $ignore_gates ) {
		$out = array();

		// Blog home: only when the front page IS the posts index. With a
		// static front page + separate posts page, both are real pages already
		// covered by the post descriptors, so nothing is synthesized.
		if ( 'posts' === get_option( 'show_on_front' ) ) {
			self::add( $out, home_url( '/' ), 'home', __( 'Blog home', 'search-social-appearance-audit' ) );
		}

		/**
		 * Toggle taxonomy term archives (categories, tags, custom taxonomies).
		 *
		 * @param bool $synthesize Default true.
		 */
		if ( $ignore_gates || apply_filters( 'ssaa_synthesize_taxonomies', true ) ) {
			// ALL public taxonomies, deliberately independent of the
			// scan_post_types setting (LIHA decision): category/tag archives
			// are covered even when only posts/pages are in post scope, and
			// product_cat/product_tag archives are covered on free even though
			// product posts are premium.
			$taxonomies = get_taxonomies( array( 'public' => true ), 'objects' );
			foreach ( $taxonomies as $taxonomy ) {
				if ( 'post_format' === $taxonomy->name ) {
					continue;
				}

				$terms = get_terms( array(
					'taxonomy'   => $taxonomy->name,
					'hide_empty' => true,
					'number'     => 0,
				) );
				if ( is_wp_error( $terms ) ) {
					continue;
				}

				// The general label (not singular_name) disambiguates
				// same-named taxonomies, e.g. blog "Categories" vs Woo
				// "Product categories".
				$tax_label = isset( $taxonomy->label ) && '' !== $taxonomy->label ? $taxonomy->label : $taxonomy->name;

				foreach ( $terms as $term ) {
					$url = get_term_link( $term );
					if ( is_wp_error( $url ) ) {
						continue;
					}
					self::add( $out, $url, 'tax', sprintf(
						/* translators: 1: taxonomy label (e.g. Categories), 2: term name */
						_x( '%1$s: %2$s', 'archive page label', 'search-social-appearance-audit' ),
						$tax_label,
						$term->name
					) );
				}
			}
		}

		/**
		 * Toggle monthly date archives.
		 *
		 * @param bool $synthesize Default true.
		 */
		if ( $ignore_gates || apply_filters( 'ssaa_synthesize_dates', true ) ) {
			foreach ( self::months_with_posts() as $month ) {
				self::add( $out, get_month_link( $month['year'], $month['month'] ), 'date', sprintf(
					/* translators: %s: month and year, e.g. "March 2026" */
					__( 'Month: %s', 'search-social-appearance-audit' ),
					date_i18n( 'F Y', mktime( 0, 0, 0, $month['month'], 1, $month['year'] ) )
				) );
			}
		}

		if ( $ignore_gates ) {
			return $out;
		}

		/**
		 * Final say over the synthesized descriptor set.
		 *
		 * @param array[] $out Synthesized descriptors.
		 */
		return (array) apply_filters( 'ssaa_synthesized_descriptors', $out );
	}

	/**
	 * Append one url-kind descriptor.
	 *
	 * @param array[] $out   Descriptor list (by reference).
	 * @param string  $url   Absolute front-end URL.
	 * @param string  $type  Archive kind ('home' | 'tax' | 'date').
	 * @param string  $label Display label (stored as the row title).
	 */
	private static function add( array &$out, $url, $type, $label ) {
		$url = (string) $url;
		if ( '' === $url ) {
			return;
		}
		$out[] = array(
			'kind'      => 'url',
			'ref'       => SSAA_Engine::url_ref( $url ),
			'type'      => $type,
			'label'     => $label,
			'permalink' => $url,
		);
	}

	/**
	 * Distinct year/month pairs that have published posts - the months with a
	 * non-empty date archive. Direct read of wp_posts (the same query core's
	 * wp_get_archives() builds), newest first.
	 *
	 * @return array[] [ { year: int, month: int }, ... ]
	 */
	private static function months_with_posts() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- aggregate on core's indexed type_status_date; same shape as wp_get_archives(); admin-only scan-build path.
		$rows = (array) $wpdb->get_results(
			"SELECT YEAR(post_date) AS y, MONTH(post_date) AS m
			FROM {$wpdb->posts}
			WHERE post_type = 'post' AND post_status = 'publish'
			GROUP BY y, m
			ORDER BY y DESC, m DESC",
			ARRAY_A
		);

		$months = array();
		foreach ( $rows as $row ) {
			$months[] = array(
				'year'  => (int) $row['y'],
				'month' => (int) $row['m'],
			);
		}
		return $months;
	}
}
