<?php
/**
 * Local deterministic Schema Health validation service.
 *
 * @package SEOvaultSEO\Schema\Validation
 */

namespace SEOvaultSEO\Schema\Validation;

use SEOvaultSEO\Schema\Validation\Rules\Core\BrokenIdReferenceRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\ContextRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\DateValueRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\DuplicateIdRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\EmptyValueRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\FaqPageStructureRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\HowToStructureRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\GraphShapeRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\TypeRule;
use SEOvaultSEO\Schema\Validation\Rules\Core\UrlValueRule;
use SEOvaultSEO\Schema\Validation\Rules\Google\GoogleRichResultRule;
use SEOvaultSEO\Schema\Validation\Rules\SchemaOrg\KnownPropertyRule;
use SEOvaultSEO\Schema\Validation\Rules\SchemaOrg\KnownTypeRule;

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

/**
 * Runs bounded syntax, normalization, and local core rules without HTTP.
 */
class SchemaValidationService {

	/**
	 * Registered local rules.
	 *
	 * @var RuleRegistry
	 */
	private $registry;

	/**
	 * Raw custom JSON-LD parser.
	 *
	 * @var JsonSyntaxValidator
	 */
	private $syntax_validator;

	/**
	 * Parsed graph normalizer.
	 *
	 * @var GraphNormalizer
	 */
	private $normalizer;

	/**
	 * Create the service with optional test doubles.
	 *
	 * @param RuleRegistry|null        $registry         Rule registry.
	 * @param JsonSyntaxValidator|null $syntax_validator JSON parser.
	 * @param GraphNormalizer|null     $normalizer       Graph normalizer.
	 */
	public function __construct( $registry = null, $syntax_validator = null, $normalizer = null ) {
		$this->registry         = $registry instanceof RuleRegistry ? $registry : $this->get_default_registry();
		$this->syntax_validator = $syntax_validator instanceof JsonSyntaxValidator ? $syntax_validator : new JsonSyntaxValidator();
		$this->normalizer       = $normalizer instanceof GraphNormalizer ? $normalizer : new GraphNormalizer();
	}

	/**
	 * Validate raw custom JSON-LD.
	 *
	 * @param mixed $json    Raw custom JSON-LD.
	 * @param array $options Validation options.
	 * @return ValidationResult
	 */
	public function validate_custom_json( $json, $options = array() ) {
		/**
		 * Fires before local Schema Health validation runs.
		 *
		 * @param string $source  Schema source identifier.
		 * @param array  $options Validation options (read-only intent).
		 */
		do_action( 'seovaultseo_before_schema_validation', 'custom', $options );

		$limits = ValidationLimits::get( $options['limits'] ?? array() );
		$parsed = $this->syntax_validator->parse( $json, $limits );
		$result = new ValidationResult( 'custom' );

		foreach ( $parsed['issues'] as $issue ) {
			$result->add_issue( $issue );
		}

		if ( empty( $parsed['is_valid'] ) ) {
			$this->finish( $result, array(), $options );
			$result = $this->filter_result( $result, 'custom' );
			/**
			 * Fires after local Schema Health validation completes.
			 *
			 * @param ValidationResult $result Completed result.
			 * @param string           $source Schema source identifier.
			 */
			do_action( 'seovaultseo_after_schema_validation', $result, 'custom' );
			return $result;
		}

		$result = $this->validate_array( $parsed['data'], 'custom', $limits, $options, $result );
		$result = $this->filter_result( $result, 'custom' );
		/**
		 * Fires after local Schema Health validation completes.
		 *
		 * @param ValidationResult $result Completed result.
		 * @param string           $source Schema source identifier.
		 */
		do_action( 'seovaultseo_after_schema_validation', $result, 'custom' );

		return $result;
	}

	/**
	 * Validate an already parsed generated, custom, or live graph.
	 *
	 * @param mixed  $graph   Parsed schema graph.
	 * @param string $source  Graph source.
	 * @param array  $options Validation options.
	 * @return ValidationResult
	 */
	public function validate( $graph, $source = 'generated', $options = array() ) {
		$source = in_array( $source, array( 'generated', 'custom', 'live' ), true ) ? $source : 'generated';

		/**
		 * Fires before local Schema Health validation runs.
		 *
		 * @param string $source  Schema source identifier.
		 * @param array  $options Validation options (read-only intent).
		 */
		do_action( 'seovaultseo_before_schema_validation', $source, $options );

		$result = new ValidationResult( $source );

		if ( ! is_array( $graph ) ) {
			$result->add_issue(
				new ValidationIssue(
					array(
						'code'        => 'json.root.invalid_type',
						'severity'    => Severity::ERROR,
						'category'    => 'core',
						'source'      => $source,
						'title_key'   => 'json.root_invalid_type.title',
						'message_key' => 'json.root_invalid_type.message',
					)
				)
			);
			$this->finish( $result, array(), $options );
			$result = $this->filter_result( $result, $source );
			/**
			 * Fires after local Schema Health validation completes.
			 *
			 * @param ValidationResult $result Completed result.
			 * @param string           $source Schema source identifier.
			 */
			do_action( 'seovaultseo_after_schema_validation', $result, $source );
			return $result;
		}

		$limits = ValidationLimits::get( $options['limits'] ?? array() );
		$result = $this->validate_array( $graph, $source, $limits, $options, $result );
		$result = $this->filter_result( $result, $source );
		/**
		 * Fires after local Schema Health validation completes.
		 *
		 * @param ValidationResult $result Completed result.
		 * @param string           $source Schema source identifier.
		 */
		do_action( 'seovaultseo_after_schema_validation', $result, $source );

		return $result;
	}

	/**
	 * Build the default core rule registry.
	 *
	 * @return RuleRegistry
	 */
	private function get_default_registry() {
		$registry = new RuleRegistry();
		$rules    = array(
			new GraphShapeRule(),
			new ContextRule(),
			new TypeRule(),
			new DuplicateIdRule(),
			new BrokenIdReferenceRule(),
			new EmptyValueRule(),
			new UrlValueRule(),
			new DateValueRule(),
			new FaqPageStructureRule(),
			new HowToStructureRule(),
			new KnownTypeRule(),
			new KnownPropertyRule(),
		);

		if ( SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::GOOGLE ) ) {
			$rules[] = new GoogleRichResultRule();
		}

		/**
		 * Filters the Schema Health rule list before registration.
		 *
		 * Only RuleInterface instances are kept. Invalid entries are dropped.
		 * This filter must not bypass permission or nonce checks.
		 *
		 * @param RuleInterface[] $rules Default rule instances.
		 * @return RuleInterface[]
		 */
		$filtered = apply_filters( 'seovaultseo_schema_validation_rules', $rules );
		$rules    = $this->sanitize_rules( $filtered );

		foreach ( $rules as $rule ) {
			$registry->add( $rule );
		}

		return $registry;
	}

	/**
	 * Keep only valid RuleInterface instances, keyed by stable code.
	 *
	 * @param mixed $filtered Filtered rule list.
	 * @return RuleInterface[]
	 */
	private function sanitize_rules( $filtered ) {
		if ( ! is_array( $filtered ) ) {
			return array();
		}

		$rules = array();
		foreach ( $filtered as $rule ) {
			if ( ! $rule instanceof RuleInterface ) {
				continue;
			}
			$code = $rule->get_code();
			if ( ! is_string( $code ) || '' === $code ) {
				continue;
			}
			$rules[ $code ] = $rule;
		}

		return array_values( $rules );
	}

	/**
	 * Normalize and validate one parsed graph.
	 *
	 * @param array            $input   Parsed graph.
	 * @param string           $source  Graph source.
	 * @param array            $limits  Effective limits.
	 * @param array            $options Validation options.
	 * @param ValidationResult $result  Result to complete.
	 * @return ValidationResult
	 */
	private function validate_array( $input, $source, $limits, $options, ValidationResult $result ) {
		$normalized = $this->normalizer->normalize( $input, $limits );
		$context    = new ValidationContext(
			$source,
			$normalized['graph'],
			$normalized['entities'],
			$limits,
			$options['canonical_url'] ?? ''
		);

		/**
		 * Filters the Schema Health validation context before rules run.
		 *
		 * Only ValidationContext instances are accepted; other returns are ignored.
		 * This filter must not bypass permission or nonce checks.
		 *
		 * @param ValidationContext $context Validation context.
		 * @param string            $source  Schema source identifier.
		 * @return ValidationContext
		 */
		$filtered_context = apply_filters( 'seovaultseo_schema_validation_context', $context, $source );
		if ( $filtered_context instanceof ValidationContext ) {
			$context = $filtered_context;
		}

		$issue_count = 0;
		$passed      = 0;

		$result->set_entities( $this->get_entity_summaries( $normalized['entities'] ) );
		foreach ( $this->registry->get_all() as $rule ) {
			if ( ! $rule->supports( $context ) || $issue_count >= $limits['max_issues'] ) {
				continue;
			}

			try {
				$issues = $rule->evaluate( $context );
			} catch ( \Throwable $exception ) {
				$result->add_issue(
					new ValidationIssue(
						array(
							'code'        => 'validator.rule.failed',
							'severity'    => Severity::INFO,
							'category'    => 'core',
							'source'      => $source,
							'title_key'   => 'validator.rule_failed.title',
							'message_key' => 'validator.rule_failed.message',
						)
					)
				);
				++$issue_count;
				continue;
			}

			$rule_issue_count = 0;
			foreach ( $issues as $issue ) {
				if ( $issue_count >= $limits['max_issues'] ) {
					break;
				}
				if ( ! $issue instanceof ValidationIssue ) {
					continue;
				}

				$result->add_issue( $issue );
				++$issue_count;
				++$rule_issue_count;
			}

			if ( 0 === $rule_issue_count ) {
				++$passed;
			}
		}

		$result->set_passed_checks( $passed );
		$this->finish( $result, $this->get_hash_input( $normalized['graph'] ), $options );

		return $result;
	}

	/**
	 * Apply the public result filter with type validation.
	 *
	 * @param ValidationResult $result Completed result.
	 * @param string           $source Schema source.
	 * @return ValidationResult
	 */
	private function filter_result( ValidationResult $result, $source ) {
		/**
		 * Filters the Schema Health validation result after checks complete.
		 *
		 * Only ValidationResult instances are accepted; other returns are ignored.
		 * This filter must not bypass permission or nonce checks.
		 *
		 * @param ValidationResult $result Completed result.
		 * @param string           $source Schema source identifier.
		 * @return ValidationResult
		 */
		$filtered = apply_filters( 'seovaultseo_schema_validation_result', $result, $source );
		if ( $filtered instanceof ValidationResult ) {
			return $filtered;
		}

		return $result;
	}

	/**
	 * Convert normalized entities to the public result summary shape.
	 *
	 * @param array $entities Normalized entities.
	 * @return array
	 */
	private function get_entity_summaries( $entities ) {
		$summaries = array();
		foreach ( $entities as $entity ) {
			$summaries[] = array(
				'type' => implode( ', ', $entity['types'] ?? array() ),
				'id'   => $entity['id'] ?? '',
			);
		}

		return $summaries;
	}

	/**
	 * Attach the stable hash and completion timestamp to a result.
	 *
	 * @param ValidationResult $result     Result to finish.
	 * @param array            $hash_input Normalized hash input.
	 * @param array            $options    Validation options.
	 * @return void
	 */
	private function finish( ValidationResult $result, $hash_input, $options ) {
		if ( ! empty( $hash_input ) ) {
			$result->set_schema_hash( 'sha256:' . hash( 'sha256', wp_json_encode( $this->canonicalize( $hash_input ) ) ) );
		}

		$result->mark_checked( isset( $options['validated_at'] ) ? (int) $options['validated_at'] : time() );
	}

	/**
	 * Retain only schema values relevant to the stable hash.
	 *
	 * @param array $graph Normalized graph.
	 * @return array
	 */
	private function get_hash_input( $graph ) {
		return array(
			'@context' => $graph['@context'] ?? null,
			'@graph'   => $graph['@graph'] ?? array(),
		);
	}

	/**
	 * Canonicalize object keys and top-level graph order for hashing.
	 *
	 * @param mixed $value     Value to canonicalize.
	 * @param bool  $sort_list Whether this list is the root graph list.
	 * @return mixed
	 */
	private function canonicalize( $value, $sort_list = false ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		if ( ValueInspector::is_list( $value ) ) {
			$normalized = array_map(
				function ( $item ) {
					return $this->canonicalize( $item );
				},
				$value
			);
			if ( $sort_list ) {
				usort(
					$normalized,
					function ( $left, $right ) {
						return strcmp( wp_json_encode( $left ), wp_json_encode( $right ) );
					}
				);
			}
			return $normalized;
		}

		ksort( $value, SORT_STRING );
		foreach ( $value as $key => $item ) {
			$value[ $key ] = $this->canonicalize( $item, '@graph' === $key );
		}

		return $value;
	}
}
