<?php
/**
 * Request-scoped SchemaBuildResult memoization with rich dependency keys.
 *
 * Prevents duplicate graph builds within one request. Keys include architecture
 * / policy versions, generation, content/instance/custom fingerprints, identity,
 * and integration-relevant values. Never caches secrets or full page HTML.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Core\Options;
use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Schema\Readiness\SchemaReadinessEvaluator;
use SEOvaultSEO\Schema\Validation\SchemaValidationCache;
use SEOvaultSEO\Utils\Logger;
use SEOvaultSEO\Utils\RequestCache;

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

/**
 * Build-result cache and bounded performance diagnostics.
 */
class SchemaBuildCache {

	const REQUEST_PREFIX = 'schema_build:';

	/**
	 * @var array{hits:int,misses:int,builds:int,timings:array<int,array>}
	 */
	private static $stats = array(
		'hits'    => 0,
		'misses'  => 0,
		'builds'  => 0,
		'timings' => array(),
	);

	/**
	 * Max timing samples retained when diagnostics are enabled.
	 */
	const MAX_TIMINGS = 20;

	/**
	 * Reset stats (tests / request end).
	 *
	 * @return void
	 */
	public static function reset_stats() {
		self::$stats = array(
			'hits'    => 0,
			'misses'  => 0,
			'builds'  => 0,
			'timings' => array(),
		);
	}

	/**
	 * Drop request-scoped build entries (does not bump generation).
	 *
	 * @return void
	 */
	public static function flush_request() {
		// RequestCache has no prefix delete; mark a flush generation token.
		$token = (int) RequestCache::get( 'schema_build_flush_token', 0 );
		RequestCache::set( 'schema_build_flush_token', $token + 1 );
	}

	/**
	 * Whether diagnostics (timings) may be recorded/exposed.
	 *
	 * @return bool
	 */
	public static function diagnostics_enabled() {
		$enabled = ( defined( 'WP_DEBUG' ) && WP_DEBUG );
		/**
		 * Enable schema performance diagnostics outside WP_DEBUG.
		 *
		 * @param bool $enabled Whether enabled.
		 */
		return (bool) apply_filters( 'seovaultseo_schema_performance_diagnostics', $enabled );
	}

	/**
	 * Snapshot of bounded metrics (empty when diagnostics disabled).
	 *
	 * @return array{hits:int,misses:int,builds:int,block_parse:array,timings:array,enabled:bool}
	 */
	public static function get_diagnostics() {
		$block = SchemaBlockParseCache::get_stats();
		$out   = array(
			'enabled'     => self::diagnostics_enabled(),
			'hits'        => (int) self::$stats['hits'],
			'misses'      => (int) self::$stats['misses'],
			'builds'      => (int) self::$stats['builds'],
			'block_parse' => $block,
			'timings'     => self::diagnostics_enabled() ? self::$stats['timings'] : array(),
		);
		return $out;
	}

	/**
	 * @return array{hits:int,misses:int,builds:int}
	 */
	public static function get_stats() {
		return array(
			'hits'   => (int) self::$stats['hits'],
			'misses' => (int) self::$stats['misses'],
			'builds' => (int) self::$stats['builds'],
		);
	}

	/**
	 * Remember a build result for this request.
	 *
	 * @param string   $cache_key Cache key.
	 * @param callable $builder   Builder returning SchemaBuildResult.
	 * @param bool     $use_cache Whether caching is allowed.
	 * @return SchemaBuildResult
	 */
	public static function remember( $cache_key, callable $builder, $use_cache = true ) {
		$cache_key = (string) $cache_key;
		if ( ! $use_cache || '' === $cache_key ) {
			++self::$stats['misses'];
			++self::$stats['builds'];
			$start  = microtime( true );
			$result = $builder();
			self::record_timing( 'build_result_uncached', $start );
			return $result instanceof SchemaBuildResult ? $result : SchemaBuildResult::empty_result();
		}

		$token   = (int) RequestCache::get( 'schema_build_flush_token', 0 );
		$req_key = self::REQUEST_PREFIX . $token . ':' . $cache_key;

		if ( RequestCache::has( $req_key ) ) {
			++self::$stats['hits'];
			$cached = RequestCache::get( $req_key );
			if ( $cached instanceof SchemaBuildResult ) {
				return $cached;
			}
		}

		++self::$stats['misses'];
		++self::$stats['builds'];
		$start  = microtime( true );
		$result = $builder();
		self::record_timing( 'build_result', $start );

		if ( ! $result instanceof SchemaBuildResult ) {
			$result = SchemaBuildResult::empty_result();
		}

		RequestCache::set( $req_key, $result );
		return $result;
	}

	/**
	 * Build a dependency-rich cache key for a post (or empty for non-posts).
	 *
	 * @param \WP_Post|\WP_Term|\WP_User|null $object  Render object.
	 * @param array                            $options Build options.
	 * @return string
	 */
	public static function make_key( $object, array $options = array() ) {
		$payload = array(
			'arch'         => SchemaArchitectureSettings::get_architecture_version(),
			'policy'       => SchemaReadinessEvaluator::POLICY_VERSION,
			'generation'   => SchemaValidationCache::get_generation(),
			'plugin'       => defined( 'SEOVAULT_SEO_VERSION' ) ? SEOVAULT_SEO_VERSION : '',
			'ignore_custom'=> ! empty( $options['ignore_custom_json'] ) ? 1 : 0,
			'schema_on'    => Options::is_schema_enabled() ? 1 : 0,
			'output_mode'  => Options::get_output_mode(),
			'identity'     => SchemaArchitectureSettings::get_publisher_identity(),
			'automatic'    => SchemaArchitectureSettings::get_automatic_components(),
			'home'         => home_url( '/' ),
		);

		if ( $object instanceof \WP_Post ) {
			$post_id = (int) $object->ID;
			$custom  = (string) PostMeta::get_schema_json( $post_id );
			$payload['post'] = array(
				'id'            => $post_id,
				'type'          => (string) $object->post_type,
				'status'        => (string) $object->post_status,
				'modified_gmt'  => (string) $object->post_modified_gmt,
				'content_hash'  => SchemaBlockParseCache::content_hash( (string) $object->post_content ),
				'title_hash'    => hash( 'sha256', (string) $object->post_title ),
				'author'        => (int) $object->post_author,
				'thumbnail'     => (int) get_post_thumbnail_id( $post_id ),
				'schemas_fp'    => self::fingerprint_meta( $post_id, '_seovault_schemas' ),
				'instances_fp'  => self::fingerprint_meta( $post_id, SchemaPostMigration::META_INSTANCES ),
				'custom_fp'     => hash( 'sha256', $custom ),
				'custom_mode'   => (string) get_post_meta( $post_id, SchemaPostMigration::META_JSON_MODE, true ),
				'custom_doc_fp' => self::fingerprint_meta( $post_id, SchemaCustomJson::META_DOCUMENT ),
				'migrated'      => SchemaPostMigration::is_post_migrated( $post_id ) ? 1 : 0,
			);

			if ( 'product' === $object->post_type ) {
				$payload['post']['woo'] = array(
					'price'  => (string) get_post_meta( $post_id, '_price', true ),
					'sku'    => (string) get_post_meta( $post_id, '_sku', true ),
					'stock'  => (string) get_post_meta( $post_id, '_stock_status', true ),
					'regular'=> (string) get_post_meta( $post_id, '_regular_price', true ),
					'sale'   => (string) get_post_meta( $post_id, '_sale_price', true ),
				);
			}
		} elseif ( $object instanceof \WP_Term ) {
			$payload['term'] = array(
				'id'   => (int) $object->term_id,
				'tax'  => (string) $object->taxonomy,
				'slug' => (string) $object->slug,
			);
		} elseif ( $object instanceof \WP_User ) {
			$payload['user'] = array(
				'id' => (int) $object->ID,
			);
		} else {
			$payload['object'] = 'none';
		}

		$encoded = wp_json_encode( $payload );
		if ( false === $encoded ) {
			$encoded = serialize( $payload ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Hash input only.
		}

		return hash( 'sha256', $encoded );
	}

	/**
	 * @param int    $post_id  Post ID.
	 * @param string $meta_key Full meta key.
	 * @return string
	 */
	private static function fingerprint_meta( $post_id, $meta_key ) {
		$raw = get_post_meta( (int) $post_id, $meta_key, true );
		if ( is_array( $raw ) || is_object( $raw ) ) {
			$encoded = wp_json_encode( $raw );
			$raw     = false === $encoded ? '' : $encoded;
		}
		return hash( 'sha256', (string) $raw );
	}

	/**
	 * @param string $label Operation label.
	 * @param float  $start Microtime start.
	 * @return void
	 */
	private static function record_timing( $label, $start ) {
		if ( ! self::diagnostics_enabled() ) {
			return;
		}
		$ms = ( microtime( true ) - (float) $start ) * 1000;
		self::$stats['timings'][] = array(
			'label' => sanitize_key( (string) $label ),
			'ms'    => round( $ms, 3 ),
		);
		if ( count( self::$stats['timings'] ) > self::MAX_TIMINGS ) {
			self::$stats['timings'] = array_slice( self::$stats['timings'], -1 * self::MAX_TIMINGS );
		}
		Logger::timing( 'schema.' . $label, $start, array( 'ms' => round( $ms, 3 ) ) );
	}
}
