<?php
/**
 * Sanitize and validate schema template display conditions.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

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

/**
 * Condition sanitizer, broad-rule detector, and scope estimator.
 */
class DisplayConditionsSanitizer {

	/**
	 * Soft max conditions per template.
	 */
	const MAX_CONDITIONS = 40;

	/**
	 * Hard max conditions.
	 */
	const HARD_MAX_CONDITIONS = 80;

	/**
	 * Soft max posts scanned for scope samples.
	 */
	const SCOPE_SAMPLE_LIMIT = 5;

	/**
	 * Soft max posts counted for scope estimates.
	 */
	const SCOPE_COUNT_CAP = 200;

	/**
	 * Allowed condition operators.
	 *
	 * @var string[]
	 */
	const OPERATORS = array( 'include', 'exclude' );

	/**
	 * Allowed categories.
	 *
	 * @var string[]
	 */
	const CATEGORIES = array( 'general', 'singular', 'archive' );

	/**
	 * Allowed general types.
	 *
	 * @var string[]
	 */
	const GENERAL_TYPES = array( 'all', 'front', 'blog', 'search', '404' );

	/**
	 * Sanitize a list of display conditions.
	 *
	 * @param mixed $conditions Raw conditions.
	 * @param array $context    Optional limits.
	 * @return array{conditions:array<int,array>,errors:string[],warnings:string[],dropped:int}
	 */
	public static function sanitize( $conditions, array $context = array() ) {
		$errors   = array();
		$warnings = array();
		$dropped  = 0;

		if ( null === $conditions || '' === $conditions ) {
			$conditions = array();
		}
		if ( ! is_array( $conditions ) ) {
			return array(
				'conditions' => array(),
				'errors'     => array( 'malformed_conditions' ),
				'warnings'   => array(),
				'dropped'    => 0,
			);
		}

		$max = isset( $context['max_conditions'] )
			? min( self::HARD_MAX_CONDITIONS, max( 1, (int) $context['max_conditions'] ) )
			: self::MAX_CONDITIONS;

		$clean = array();
		foreach ( $conditions as $raw ) {
			if ( count( $clean ) >= $max ) {
				++$dropped;
				$warnings[] = 'conditions_truncated';
				break;
			}
			if ( ! is_array( $raw ) ) {
				++$dropped;
				continue;
			}

			$row = self::sanitize_one( $raw );
			if ( null === $row ) {
				++$dropped;
				continue;
			}

			$stale = self::stale_reference_reason( $row );
			if ( '' !== $stale ) {
				++$dropped;
				$warnings[] = $stale;
				continue;
			}

			$clean[] = $row;
		}

		return array(
			'conditions' => array_values( $clean ),
			'errors'     => array_values( array_unique( $errors ) ),
			'warnings'   => array_values( array_unique( $warnings ) ),
			'dropped'    => $dropped,
		);
	}

	/**
	 * Sanitize one condition row.
	 *
	 * @param array $raw Raw row.
	 * @return array|null
	 */
	public static function sanitize_one( array $raw ) {
		$operator = isset( $raw['condition'] ) ? sanitize_key( (string) $raw['condition'] ) : 'include';
		if ( ! in_array( $operator, self::OPERATORS, true ) ) {
			$operator = 'include';
		}

		$category = isset( $raw['category'] ) ? sanitize_key( (string) $raw['category'] ) : 'general';
		if ( ! in_array( $category, self::CATEGORIES, true ) ) {
			return null;
		}

		$type     = isset( $raw['type'] ) ? sanitize_key( (string) $raw['type'] ) : 'all';
		$value    = isset( $raw['value'] ) ? absint( $raw['value'] ) : 0;
		$taxonomy = isset( $raw['postTaxonomy'] ) ? sanitize_key( (string) $raw['postTaxonomy'] ) : '';

		if ( 'general' === $category ) {
			if ( ! in_array( $type, self::GENERAL_TYPES, true ) ) {
				return null;
			}
			$taxonomy = '';
			$value    = 0;
		} elseif ( 'singular' === $category ) {
			if ( '' === $type ) {
				return null;
			}
			if ( 'all' !== $type && 'post' !== $type && 'page' !== $type && ! post_type_exists( $type ) ) {
				// Allow unknown CPT keys that may be registered later; keep sanitized key.
				$type = sanitize_key( $type );
			}
			if ( $taxonomy && ! taxonomy_exists( $taxonomy ) ) {
				$taxonomy = '';
			}
		} elseif ( 'archive' === $category ) {
			if ( '' === $type ) {
				return null;
			}
			if ( ! in_array( $type, array( 'all', 'author', 'category', 'post_tag' ), true ) && ! taxonomy_exists( $type ) ) {
				$type = sanitize_key( $type );
			}
			$taxonomy = '';
		}

		return array(
			'condition'    => $operator,
			'category'     => $category,
			'type'         => $type,
			'value'        => $value,
			'postTaxonomy' => $taxonomy,
		);
	}

	/**
	 * Whether a specialized template's include rules are unqualified broad targeting.
	 *
	 * Blocks site-wide / all-posts / all-pages includes. Custom post type targeting
	 * (e.g. all `event` posts) remains allowed.
	 *
	 * @param array $conditions Sanitized conditions.
	 * @return bool
	 */
	public static function has_unqualified_broad_include( array $conditions ) {
		foreach ( $conditions as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			if ( 'include' !== ( $row['condition'] ?? 'include' ) ) {
				continue;
			}
			if ( self::is_unqualified_broad_row( $row ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether one include row is an unqualified broad target.
	 *
	 * @param array $row Condition row.
	 * @return bool
	 */
	public static function is_unqualified_broad_row( array $row ) {
		$category = (string) ( $row['category'] ?? '' );
		$type     = (string) ( $row['type'] ?? '' );
		$value    = (int) ( $row['value'] ?? 0 );
		$taxonomy = (string) ( $row['postTaxonomy'] ?? '' );

		if ( 'general' === $category && 'all' === $type ) {
			return true;
		}
		if ( 'singular' === $category && 'all' === $type ) {
			return true;
		}
		if ( 'singular' === $category && in_array( $type, array( 'post', 'page' ), true ) && 0 === $value && '' === $taxonomy ) {
			return true;
		}
		return false;
	}

	/**
	 * Validate targeting for a specialized schema type.
	 *
	 * @param string $type       Schema type.
	 * @param array  $conditions Sanitized conditions.
	 * @return array{ok:bool,errors:string[],warnings:string[]}
	 */
	public static function validate_specialized_targeting( $type, array $conditions ) {
		$type     = is_string( $type ) ? $type : '';
		$errors   = array();
		$warnings = array();

		if ( '' === $type || ! SchemaTypePolicyRegistry::is_specialized( $type ) ) {
			return array(
				'ok'       => true,
				'errors'   => array(),
				'warnings' => array(),
			);
		}

		$includes = array();
		foreach ( $conditions as $row ) {
			if ( is_array( $row ) && 'include' === ( $row['condition'] ?? 'include' ) ) {
				$includes[] = $row;
			}
		}

		if ( empty( $includes ) ) {
			$errors[] = 'missing_include';
		}
		if ( self::has_unqualified_broad_include( $conditions ) ) {
			$errors[] = 'broad_targeting_not_allowed';
		}

		return array(
			'ok'       => empty( $errors ),
			'errors'   => $errors,
			'warnings' => $warnings,
		);
	}

	/**
	 * Estimate matched public content scope for include rules.
	 *
	 * @param array $conditions Sanitized conditions.
	 * @return array{
	 *   count:int|null,
	 *   capped:bool,
	 *   label:string,
	 *   samples:array<int,array{id:int,title:string,type:string}>,
	 *   broad:bool,
	 *   notes:string[]
	 * }
	 */
	public static function estimate_scope( array $conditions ) {
		$notes   = array();
		$samples = array();
		$broad   = self::has_unqualified_broad_include( $conditions );

		if ( $broad ) {
			return array(
				'count'   => null,
				'capped'  => false,
				'label'   => __( 'Entire site / all posts or pages (not allowed for specialized templates)', 'seovault-ai' ),
				'samples' => array(),
				'broad'   => true,
				'notes'   => array( 'broad_targeting' ),
			);
		}

		$includes = array();
		foreach ( $conditions as $row ) {
			if ( is_array( $row ) && 'include' === ( $row['condition'] ?? 'include' ) ) {
				$includes[] = $row;
			}
		}

		if ( empty( $includes ) ) {
			return array(
				'count'   => 0,
				'capped'  => false,
				'label'   => __( 'No include rules — template will not match any content', 'seovault-ai' ),
				'samples' => array(),
				'broad'   => false,
				'notes'   => array( 'missing_include' ),
			);
		}

		$total  = 0;
		$capped = false;
		$ids    = array();

		foreach ( $includes as $row ) {
			$partial = self::estimate_include_row( $row );
			$total  += (int) $partial['count'];
			if ( ! empty( $partial['capped'] ) ) {
				$capped = true;
			}
			foreach ( $partial['samples'] as $sample ) {
				$ids[ (int) $sample['id'] ] = $sample;
			}
			foreach ( $partial['notes'] as $note ) {
				$notes[] = $note;
			}
		}

		if ( $total > self::SCOPE_COUNT_CAP ) {
			$capped = true;
			$total  = self::SCOPE_COUNT_CAP;
		}

		$samples = array_slice( array_values( $ids ), 0, self::SCOPE_SAMPLE_LIMIT );
		$label   = $capped
			? sprintf(
				/* translators: %d: approximate match count */
				__( 'About %d+ matching items (estimate capped)', 'seovault-ai' ),
				$total
			)
			: sprintf(
				/* translators: %d: match count */
				_n( '%d matching item', '%d matching items', $total, 'seovault-ai' ),
				$total
			);

		return array(
			'count'   => $total,
			'capped'  => $capped,
			'label'   => $label,
			'samples' => $samples,
			'broad'   => false,
			'notes'   => array_values( array_unique( $notes ) ),
		);
	}

	/**
	 * Estimate one include row.
	 *
	 * @param array $row Condition.
	 * @return array{count:int,capped:bool,samples:array,notes:string[]}
	 */
	private static function estimate_include_row( array $row ) {
		$category = (string) ( $row['category'] ?? '' );
		$type     = (string) ( $row['type'] ?? '' );
		$value    = (int) ( $row['value'] ?? 0 );
		$taxonomy = (string) ( $row['postTaxonomy'] ?? '' );
		$notes    = array();

		if ( 'general' === $category ) {
			if ( 'front' === $type ) {
				$page_id = (int) get_option( 'page_on_front' );
				return self::sample_from_ids( $page_id > 0 ? array( $page_id ) : array(), $notes );
			}
			if ( 'blog' === $type ) {
				$page_id = (int) get_option( 'page_for_posts' );
				return self::sample_from_ids( $page_id > 0 ? array( $page_id ) : array(), $notes );
			}
			$notes[] = 'context_only_' . $type;
			return array(
				'count'   => 0,
				'capped'  => false,
				'samples' => array(),
				'notes'   => $notes,
			);
		}

		if ( 'singular' === $category ) {
			if ( $value > 0 && '' === $taxonomy ) {
				return self::sample_from_ids( array( $value ), $notes );
			}
			if ( $taxonomy && $value > 0 ) {
				$query = new \WP_Query(
					array(
						'post_type'              => ( 'all' === $type ) ? 'any' : $type,
						'post_status'            => 'publish',
						'posts_per_page'         => self::SCOPE_SAMPLE_LIMIT,
						'fields'                 => 'ids',
						'no_found_rows'          => false,
						'update_post_meta_cache' => false,
						'update_post_term_cache' => false,
						'tax_query'              => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
							array(
								'taxonomy' => $taxonomy,
								'field'    => 'term_id',
								'terms'    => array( $value ),
							),
						),
					)
				);
				$count = min( self::SCOPE_COUNT_CAP, (int) $query->found_posts );
				return array(
					'count'   => $count,
					'capped'  => ( (int) $query->found_posts ) > self::SCOPE_COUNT_CAP,
					'samples' => self::ids_to_samples( $query->posts ),
					'notes'   => $notes,
				);
			}

			$post_type = ( 'all' === $type ) ? 'any' : $type;
			if ( 'any' !== $post_type && ! post_type_exists( $post_type ) ) {
				$notes[] = 'unknown_post_type';
				return array(
					'count'   => 0,
					'capped'  => false,
					'samples' => array(),
					'notes'   => $notes,
				);
			}

			if ( 'any' !== $post_type ) {
				$counts = wp_count_posts( $post_type );
				$count  = isset( $counts->publish ) ? (int) $counts->publish : 0;
				$query  = new \WP_Query(
					array(
						'post_type'              => $post_type,
						'post_status'            => 'publish',
						'posts_per_page'         => self::SCOPE_SAMPLE_LIMIT,
						'fields'                 => 'ids',
						'no_found_rows'          => true,
						'update_post_meta_cache' => false,
						'update_post_term_cache' => false,
					)
				);
				return array(
					'count'   => min( self::SCOPE_COUNT_CAP, $count ),
					'capped'  => $count > self::SCOPE_COUNT_CAP,
					'samples' => self::ids_to_samples( $query->posts ),
					'notes'   => $notes,
				);
			}
		}

		if ( 'archive' === $category ) {
			$notes[] = 'archive_context';
			return array(
				'count'   => 0,
				'capped'  => false,
				'samples' => array(),
				'notes'   => $notes,
			);
		}

		return array(
			'count'   => 0,
			'capped'  => false,
			'samples' => array(),
			'notes'   => $notes,
		);
	}

	/**
	 * @param int[]    $ids   Post IDs.
	 * @param string[] $notes Notes.
	 * @return array{count:int,capped:bool,samples:array,notes:string[]}
	 */
	private static function sample_from_ids( array $ids, array $notes ) {
		$valid = array();
		foreach ( $ids as $id ) {
			$id = (int) $id;
			if ( $id <= 0 ) {
				continue;
			}
			$post = get_post( $id );
			if ( ! $post instanceof \WP_Post ) {
				$notes[] = 'deleted_post';
				continue;
			}
			if ( ! in_array( $post->post_status, array( 'publish', 'private' ), true ) ) {
				$notes[] = 'non_public_post';
				continue;
			}
			$valid[] = $id;
		}
		return array(
			'count'   => count( $valid ),
			'capped'  => false,
			'samples' => self::ids_to_samples( $valid ),
			'notes'   => $notes,
		);
	}

	/**
	 * @param int[] $ids Post IDs.
	 * @return array<int,array{id:int,title:string,type:string}>
	 */
	private static function ids_to_samples( $ids ) {
		$out = array();
		foreach ( (array) $ids as $id ) {
			$id = (int) $id;
			$post = get_post( $id );
			if ( ! $post instanceof \WP_Post ) {
				continue;
			}
			$out[] = array(
				'id'    => $id,
				'title' => get_the_title( $post ),
				'type'  => $post->post_type,
			);
		}
		return $out;
	}

	/**
	 * Stale reference reason or empty string when valid.
	 *
	 * @param array $row Condition.
	 * @return string
	 */
	private static function stale_reference_reason( array $row ) {
		$category = (string) ( $row['category'] ?? '' );
		$value    = (int) ( $row['value'] ?? 0 );
		$taxonomy = (string) ( $row['postTaxonomy'] ?? '' );

		if ( 'singular' === $category && $value > 0 && '' === $taxonomy ) {
			$post = get_post( $value );
			if ( ! $post instanceof \WP_Post ) {
				return 'deleted_post_reference';
			}
		}

		if ( 'singular' === $category && $taxonomy && $value > 0 ) {
			$term = get_term( $value, $taxonomy );
			if ( ! $term || is_wp_error( $term ) ) {
				return 'deleted_term_reference';
			}
		}

		if ( 'archive' === $category && $value > 0 ) {
			$type = (string) ( $row['type'] ?? '' );
			if ( 'author' === $type ) {
				$user = get_user_by( 'id', $value );
				if ( ! $user ) {
					return 'deleted_author_reference';
				}
			} elseif ( in_array( $type, array( 'category', 'post_tag' ), true ) || taxonomy_exists( $type ) ) {
				$tax  = in_array( $type, array( 'category', 'post_tag' ), true ) ? $type : $type;
				$term = get_term( $value, $tax );
				if ( ! $term || is_wp_error( $term ) ) {
					return 'deleted_term_reference';
				}
			}
		}

		return '';
	}
}
