<?php
/**
 * Published-page Schema Health orchestration.
 *
 * @package SEOvaultSEO\Schema\Validation
 */

namespace SEOvaultSEO\Schema\Validation;

use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Schema\SchemaManager;

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

/**
 * Compares the renderer graph to JSON-LD observed on the public page.
 *
 * Manual AJAX only — never invoked from save_post or front-end rendering.
 */
class SchemaLiveValidationService {

	const SCOPE_PUBLISHED_PAGE = 'published_page';
	const CACHE_MAX_AGE        = 3600;

	/**
	 * @var LiveUrlPolicy
	 */
	private $policy;

	/**
	 * @var LivePageFetcher
	 */
	private $fetcher;

	/**
	 * @var JsonLdHtmlExtractor
	 */
	private $extractor;

	/**
	 * @var SchemaGraphComparator
	 */
	private $comparator;

	/**
	 * @param LiveUrlPolicy|null         $policy     Optional policy.
	 * @param LivePageFetcher|null       $fetcher    Optional fetcher.
	 * @param JsonLdHtmlExtractor|null   $extractor  Optional extractor.
	 * @param SchemaGraphComparator|null $comparator Optional comparator.
	 */
	public function __construct( $policy = null, $fetcher = null, $extractor = null, $comparator = null ) {
		$this->policy     = $policy instanceof LiveUrlPolicy ? $policy : new LiveUrlPolicy();
		$this->fetcher    = $fetcher instanceof LivePageFetcher ? $fetcher : new LivePageFetcher( $this->policy );
		$this->extractor  = $extractor instanceof JsonLdHtmlExtractor ? $extractor : new JsonLdHtmlExtractor();
		$this->comparator = $comparator instanceof SchemaGraphComparator ? $comparator : new SchemaGraphComparator();
	}

	/**
	 * Run a published-page comparison for a post.
	 *
	 * @param int  $post_id Post ID.
	 * @param bool $force   Skip a matching compact cache entry.
	 * @return array
	 */
	public function validate( $post_id, $force = false ) {
		if ( ! SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LIVE ) ) {
			return $this->response(
				'unavailable',
				__( 'Published-page validation is not available.', 'seovault-ai' )
			);
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return $this->response( 'unavailable', __( 'This post cannot be checked.', 'seovault-ai' ) );
		}
		if ( ! PostSchemaValidationService::is_supported_post_type( $post->post_type ) ) {
			return $this->response( 'unavailable', __( 'This post cannot be checked.', 'seovault-ai' ) );
		}

		if ( ! $this->is_publicly_accessible( $post ) ) {
			return $this->response(
				'not_public',
				__( 'Published-page validation becomes available when the page is publicly accessible.', 'seovault-ai' )
			);
		}

		$url = $this->policy->resolve_post_url( $post_id );
		if ( '' === $url ) {
			return $this->response(
				'unavailable',
				__( 'No safe same-site public URL is available for this post.', 'seovault-ai' )
			);
		}

		$expected = SchemaManager::get_instance()->build_result(
			$post,
			array( 'bypass_cache' => true )
		)->get_graph();
		$expected_hash = $this->hash_graph( $expected );

		if ( ! $force ) {
			$cached = $this->get_cached_response( $post_id, $expected_hash );
			if ( null !== $cached ) {
				return $cached;
			}
		}

		$fetch = $this->fetcher->fetch( $url, $post_id );
		if ( ! $fetch['ok'] ) {
			return $this->response(
				'unavailable',
				'' !== $fetch['message']
					? $fetch['message']
					: __( 'The published page could not be fetched.', 'seovault-ai' ),
				array(
					'fetch_code' => $fetch['code'],
				)
			);
		}

		$extraction = $this->extractor->extract( $fetch['body'] );
		$observed   = $this->extractor->to_graph( $extraction );
		$comparison = $this->comparator->compare( $expected, $observed );
		$faq_conflicts = self::detect_faq_conflicts( $expected, $observed, $comparison );
		$public_duplicates = self::detect_public_duplicates( $expected, $observed, $comparison );
		if ( ! empty( $faq_conflicts ) ) {
			$comparison['faq_conflicts'] = $faq_conflicts;
		}
		if ( ! empty( $public_duplicates ) ) {
			$comparison['public_duplicates'] = $public_duplicates;
		}

		$state = $comparison['matches'] ? 'matches' : 'differs';
		$message = $comparison['matches']
			? __( 'Published-page schema matches the expected SEOvault output.', 'seovault-ai' )
			: __( 'Live output differs from generated output.', 'seovault-ai' );
		if ( ! empty( $faq_conflicts['has_duplicate_faq'] ) ) {
			$state   = 'differs';
			$message = __( 'Live output differs from generated output: an extra FAQPage was detected on the page.', 'seovault-ai' );
		} elseif ( ! empty( $public_duplicates['has_duplicates'] ) ) {
			$state   = 'differs';
			$types   = isset( $public_duplicates['duplicate_types'] ) && is_array( $public_duplicates['duplicate_types'] )
				? $public_duplicates['duplicate_types']
				: array();
			$message = sprintf(
				/* translators: %s: schema types with duplicates. */
				__( 'Live output differs from generated output: extra public schema was detected (%s).', 'seovault-ai' ),
				implode( ', ', $types )
			);
		}

		$response = $this->response(
			$state,
			$message,
			array(
				'comparison'        => $comparison,
				'faq_conflicts'     => $faq_conflicts,
				'public_duplicates' => $public_duplicates,
				'block_count'       => count( $extraction['blocks'] ),
				'decode_errors'     => count( $extraction['decode_errors'] ),
				'entity_count'      => count( $extraction['entities'] ),
				'url'               => $fetch['final_url'] ? $fetch['final_url'] : $url,
			)
		);

		$this->store_cached_response( $post_id, $expected_hash, $response );

		return $response;
	}

	/**
	 * Whether the post is publicly reachable without auth/preview.
	 *
	 * @param \WP_Post $post Post.
	 * @return bool
	 */
	public function is_publicly_accessible( $post ) {
		if ( ! $post instanceof \WP_Post ) {
			return false;
		}

		if ( ! in_array( $post->post_status, array( 'publish' ), true ) ) {
			return false;
		}

		if ( '' !== (string) $post->post_password ) {
			return false;
		}

		$type = get_post_type_object( $post->post_type );
		if ( ! $type || ! is_post_type_viewable( $type ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Remove cached live validation metadata.
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	public static function delete_cache( $post_id ) {
		SchemaValidationCache::delete_live( $post_id );
	}

	/**
	 * @param int    $post_id       Post ID.
	 * @param string $expected_hash Expected graph hash.
	 * @return array|null
	 */
	private function get_cached_response( $post_id, $expected_hash ) {
		$stored_hash = PostMeta::get( $post_id, 'schema_live_hash', '' );
		$stored_at   = (int) PostMeta::get( $post_id, 'schema_live_time', 0 );
		$response    = PostMeta::get( $post_id, 'schema_live_result', array() );
		$has_row     = ( is_string( $stored_hash ) && '' !== $stored_hash )
			|| $stored_at > 0
			|| ( is_array( $response ) && ! empty( $response ) );

		if ( ! is_string( $stored_hash ) || ! hash_equals( $expected_hash, $stored_hash ) ) {
			if ( $has_row ) {
				SchemaValidationCache::delete_live( $post_id );
			}
			return null;
		}
		if ( $stored_at <= 0 || ( time() - $stored_at ) > self::CACHE_MAX_AGE ) {
			SchemaValidationCache::delete_live( $post_id );
			return null;
		}
		if ( ! is_array( $response ) || ! isset( $response['state'], $response['scope'], $response['message'] ) ) {
			SchemaValidationCache::delete_live( $post_id );
			return null;
		}

		return $response;
	}

	/**
	 * @param int    $post_id       Post ID.
	 * @param string $expected_hash Expected graph hash.
	 * @param array  $response      Response payload.
	 * @return void
	 */
	private function store_cached_response( $post_id, $expected_hash, $response ) {
		SchemaValidationInvalidator::begin_quiet();
		try {
			PostMeta::set( $post_id, 'schema_live_result', $response );
			PostMeta::set( $post_id, 'schema_live_hash', $expected_hash );
			PostMeta::set( $post_id, 'schema_live_time', time() );
		} finally {
			SchemaValidationInvalidator::end_quiet();
		}
	}

	/**
	 * @param array $graph Graph.
	 * @return string
	 */
	private function hash_graph( $graph ) {
		$comparison = $this->comparator->compare( is_array( $graph ) ? $graph : array(), array() );

		return $comparison['expected_hash'] . ':g' . SchemaValidationCache::get_generation();
	}

	/**
	 * Detect FAQPage duplication between expected SEOvault output and live markup.
	 *
	 * @param array $expected   Expected graph.
	 * @param array $observed   Observed graph.
	 * @param array $comparison Comparator result.
	 * @return array{
	 *   has_duplicate_faq:bool,
	 *   code:string,
	 *   expected_count:int,
	 *   observed_count:int,
	 *   extra:array
	 * }
	 */
	public static function detect_faq_conflicts( $expected, $observed, array $comparison = array() ) {
		$result = self::detect_type_conflicts( $expected, $observed, $comparison, array( 'FAQPage' ), 'live.faq.duplicate_third_party' );

		return array(
			'has_duplicate_faq' => ! empty( $result['has_duplicates'] ),
			'code'              => isset( $result['code'] ) ? (string) $result['code'] : '',
			'expected_count'    => isset( $result['expected_count'] ) ? (int) $result['expected_count'] : 0,
			'observed_count'    => isset( $result['observed_count'] ) ? (int) $result['observed_count'] : 0,
			'extra'             => isset( $result['extra'] ) && is_array( $result['extra'] ) ? $result['extra'] : array(),
		);
	}

	/**
	 * Detect duplicate public Article/Product/FAQ entities vs expected SEOvault output.
	 *
	 * Does not claim ownership of third-party markup; reports observed extras only.
	 *
	 * @param array $expected   Expected graph.
	 * @param array $observed   Observed graph.
	 * @param array $comparison Comparator result.
	 * @return array{
	 *   has_duplicates:bool,
	 *   duplicate_types:string[],
	 *   by_type:array<string,array>,
	 *   code:string
	 * }
	 */
	public static function detect_public_duplicates( $expected, $observed, array $comparison = array() ) {
		$watch = array(
			'Article'      => 'live.article.duplicate_third_party',
			'BlogPosting'  => 'live.article.duplicate_third_party',
			'NewsArticle'  => 'live.article.duplicate_third_party',
			'Product'      => 'live.product.duplicate_third_party',
			'FAQPage'      => 'live.faq.duplicate_third_party',
		);

		$by_type         = array();
		$duplicate_types = array();

		foreach ( $watch as $type => $code ) {
			$result = self::detect_type_conflicts( $expected, $observed, $comparison, array( $type ), $code );
			if ( ! empty( $result['has_duplicates'] ) ) {
				$by_type[ $type ] = $result;
				$duplicate_types[] = $type;
			}
		}

		// Collapse Article subtypes into one reported label when any match.
		$article_hit = array_intersect( array( 'Article', 'BlogPosting', 'NewsArticle' ), $duplicate_types );
		$labels      = array();
		if ( ! empty( $article_hit ) ) {
			$labels[] = 'Article';
		}
		if ( in_array( 'Product', $duplicate_types, true ) ) {
			$labels[] = 'Product';
		}
		if ( in_array( 'FAQPage', $duplicate_types, true ) ) {
			$labels[] = 'FAQPage';
		}

		return array(
			'has_duplicates'  => ! empty( $duplicate_types ),
			'duplicate_types' => array_values( array_unique( $labels ) ),
			'by_type'         => $by_type,
			'code'            => ! empty( $labels ) ? 'live.public.duplicate_third_party' : '',
		);
	}

	/**
	 * Detect extra observed nodes for one or more @types.
	 *
	 * @param array    $expected   Expected graph.
	 * @param array    $observed   Observed graph.
	 * @param array    $comparison Comparator result.
	 * @param string[] $types      Types to watch.
	 * @param string   $code       Issue code when duplicates exist.
	 * @return array{
	 *   has_duplicates:bool,
	 *   code:string,
	 *   expected_count:int,
	 *   observed_count:int,
	 *   extra:array
	 * }
	 */
	public static function detect_type_conflicts( $expected, $observed, array $comparison, array $types, $code ) {
		$expected_nodes = self::collect_typed_node_summaries( $expected, $types );
		$observed_nodes = self::collect_typed_node_summaries( $observed, $types );

		$extra         = array();
		$observed_only = isset( $comparison['observed_only'] ) && is_array( $comparison['observed_only'] )
			? $comparison['observed_only']
			: array();
		foreach ( $observed_only as $node ) {
			if ( ! is_array( $node ) ) {
				continue;
			}
			$node_types = isset( $node['types'] ) && is_array( $node['types'] ) ? $node['types'] : array();
			foreach ( $types as $type ) {
				if ( in_array( $type, $node_types, true ) ) {
					$extra[] = $node;
					break;
				}
			}
		}

		if ( empty( $extra ) && count( $observed_nodes ) > count( $expected_nodes ) ) {
			$expected_ids = array();
			foreach ( $expected_nodes as $node ) {
				if ( '' !== (string) ( $node['id'] ?? '' ) ) {
					$expected_ids[ (string) $node['id'] ] = true;
				}
			}
			foreach ( $observed_nodes as $node ) {
				$id = (string) ( $node['id'] ?? '' );
				if ( '' === $id || ! isset( $expected_ids[ $id ] ) ) {
					$extra[] = $node;
				}
			}
		}

		$has_duplicate = ! empty( $extra ) || ( count( $expected_nodes ) > 0 && count( $observed_nodes ) > count( $expected_nodes ) );

		return array(
			'has_duplicates' => $has_duplicate,
			'code'           => $has_duplicate ? (string) $code : '',
			'expected_count' => count( $expected_nodes ),
			'observed_count' => count( $observed_nodes ),
			'extra'          => $extra,
		);
	}

	/**
	 * @param mixed    $graph Graph envelope or node list.
	 * @param string[] $types Types to collect.
	 * @return array<int,array{id:string,types:array}>
	 */
	private static function collect_typed_node_summaries( $graph, array $types ) {
		$nodes = array();
		if ( ! is_array( $graph ) ) {
			return $nodes;
		}
		if ( isset( $graph['@graph'] ) && is_array( $graph['@graph'] ) ) {
			$list = ValueInspector::is_list( $graph['@graph'] ) ? $graph['@graph'] : array( $graph['@graph'] );
		} elseif ( ValueInspector::is_list( $graph ) ) {
			$list = $graph;
		} else {
			$list = array( $graph );
		}

		foreach ( $list as $node ) {
			if ( ! is_array( $node ) ) {
				continue;
			}
			$node_types = ValueInspector::get_types( $node );
			$match      = false;
			foreach ( $types as $type ) {
				if ( in_array( $type, $node_types, true ) ) {
					$match = true;
					break;
				}
			}
			if ( ! $match ) {
				continue;
			}
			$nodes[] = array(
				'id'    => ValueInspector::get_id( $node ),
				'types' => $node_types,
			);
		}

		return $nodes;
	}

	/**
	 * @param string $state   Result state.
	 * @param string $message Human message.
	 * @param array  $extra   Extra fields.
	 * @return array
	 */
	private function response( $state, $message, $extra = array() ) {
		$payload = array(
			'state'   => $state,
			'scope'   => self::SCOPE_PUBLISHED_PAGE,
			'message' => $message,
			'live'    => array_merge(
				array(
					'comparison'    => null,
					'block_count'   => 0,
					'decode_errors' => 0,
					'entity_count'  => 0,
					'url'           => '',
				),
				$extra
			),
		);

		return $payload;
	}
}
