<?php
/**
 * Shadow product public access guard.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChangesWooCommerce\CoreCompat\Recording;

// phpcs:disable WordPress.DB -- Scheduler-owned custom tables require direct wpdb queries.
// phpcs:disable PluginCheck.Security.DirectDB -- Table names are resolved from scheduler-owned allowlists.

use ScheduledChanges\Recording\RecordingWorkspaceMetadata;

/**
 * Registers and enforces the non-public recording product status.
 */
final class ShadowProductAccessGuard {
	public const POST_STATUS              = 'sc_recording';
	public const LEGACY_POST_STATUS       = 'scheduled_changes_recording';
	public const SHADOW_META_KEY          = RecordingWorkspaceMetadata::WORKSPACE_MARKER;
	public const SESSION_META_KEY         = RecordingWorkspaceMetadata::SESSION_UUID;
	public const SOURCE_META_KEY          = '_scheduled_changes_source_product_id';
	public const SOURCE_OBJECT_META       = '_scheduled_changes_source_object_id';
	public const SOURCE_TYPE_META         = '_scheduled_changes_source_object_type';
	public const INTENDED_SLUG_META       = '_scheduled_changes_recorded_product_slug';
	public const INTENDED_STATUS_META     = '_scheduled_changes_recorded_product_status';
	public const INTENDED_VISIBILITY_META = '_scheduled_changes_recorded_catalog_visibility';

	/**
	 * Registers WordPress hooks.
	 */
	public function register(): void {
		add_action( 'init', array( $this, 'registerPostStatus' ), 5 );
		add_filter( 'woocommerce_product_is_visible', array( $this, 'hideWooCommerceProduct' ), 10, 2 );
		add_filter( 'the_posts', array( $this, 'removeFromPublicQueries' ), 10, 2 );
		add_filter( 'wp_sitemaps_posts_entry', array( $this, 'removeFromSitemaps' ), 10, 3 );
		add_filter( 'rest_pre_dispatch', array( $this, 'blockPublicRestAccess' ), 10, 3 );
		add_filter( 'woocommerce_product_data_store_cpt_get_products_query', array( $this, 'includeShadowVariationsInAdminQueries' ), 10, 2 );
			add_filter( 'woocommerce_admin_meta_boxes_variations_count', array( $this, 'shadowVariationCount' ), 10, 2 );
			add_filter( 'wc_product_pre_has_unique_sku', array( $this, 'ignoreShadowsForSkuUniqueness' ), 10, 3 );
			add_filter( 'wc_product_has_unique_sku', array( $this, 'ignoreShadowsForSkuUniquenessResult' ), 10, 3 );
			add_filter( 'wc_product_pre_has_global_unique_id', array( $this, 'ignoreShadowsForGlobalUniqueIdUniqueness' ), 10, 3 );
		add_filter( 'woocommerce_get_product_id_by_sku', array( $this, 'ignoreShadowSkuOwner' ), 10, 2 );
		add_filter( 'woocommerce_get_product_id_by_global_unique_id', array( $this, 'ignoreShadowGlobalUniqueIdOwner' ), 10, 2 );
		add_action( 'woocommerce_new_product_variation', array( $this, 'protectShadowVariation' ), 100, 2 );
		add_action( 'woocommerce_save_product_variation', array( $this, 'protectShadowVariation' ), 100, 2 );
		add_action( 'template_redirect', array( $this, 'blockSingularTemplate' ), 0 );
	}

	/**
	 * Registers the custom status immediately when a service needs it.
	 */
	public static function ensurePostStatusRegistered(): void {
		if ( null !== get_post_status_object( self::POST_STATUS ) ) {
			return;
		}

		register_post_status(
			self::POST_STATUS,
			array(
				'label'                     => _x( 'Scheduled Changes Recording', 'post status', 'jjs-post-scheduler' ),
				'public'                    => false,
				'internal'                  => false,
				'exclude_from_search'       => true,
				'publicly_queryable'        => false,
				'show_in_admin_all_list'    => false,
				'show_in_admin_status_list' => true,
				/* translators: %s: number of recording shadow products. */
				'label_count'               => _n_noop(
					'Scheduled Changes Recording <span class="count">(%s)</span>',
					'Scheduled Changes Recording <span class="count">(%s)</span>',
					'jjs-post-scheduler'
				),
			)
		);
	}

	/**
	 * Registers the custom status on init.
	 */
	public function registerPostStatus(): void {
		self::ensurePostStatusRegistered();
	}

	/**
	 * Marks a product or variation as a recording shadow.
	 */
	public static function markShadow(
		int $object_id,
		string $session_uuid,
		int $source_product_id,
		int $source_object_id,
		string $source_object_type
	): void {
		update_post_meta( $object_id, self::SHADOW_META_KEY, '1' );
		update_post_meta( $object_id, self::SESSION_META_KEY, $session_uuid );
		update_post_meta( $object_id, self::SOURCE_META_KEY, (string) $source_product_id );
		update_post_meta( $object_id, self::SOURCE_OBJECT_META, (string) $source_object_id );
		update_post_meta( $object_id, self::SOURCE_TYPE_META, $source_object_type );
	}

	/**
	 * Whether a post ID is a recording shadow.
	 */
	public static function isShadow( int $post_id ): bool {
		return $post_id > 0 && '1' === get_post_meta( $post_id, self::SHADOW_META_KEY, true );
	}

	/**
	 * Prevents a recording shadow from reserving its edited SKU.
	 */
	public function ignoreShadowsForSkuUniqueness( ?bool $unique, int $product_id, string $sku ): ?bool {
		return $this->ignoreShadowsForUniqueValue( $unique, $product_id, 'sku', $sku );
	}

		/**
		 * Prevents shadows from causing legacy WooCommerce SKU uniqueness failures.
		 */
	public function ignoreShadowsForSkuUniquenessResult( bool $sku_found, int $product_id, string $sku ): bool {
		if ( ! $sku_found || '' === $sku ) {
			return $sku_found;
		}

		if ( ! self::isShadow( $product_id ) && ! $this->hasShadowUniqueValueOwner( 'sku', $sku ) ) {
			return $sku_found;
		}

		$excluded_ids = array( $product_id );

		if ( self::isShadow( $product_id ) ) {
			$source_object_id = (int) get_post_meta( $product_id, self::SOURCE_OBJECT_META, true );

			if ( $source_object_id > 0 ) {
				$excluded_ids[] = $source_object_id;
			}
		}

		return 0 !== $this->realUniqueValueOwnerId( 'sku', $sku, $excluded_ids );
	}

	/**
	 * Prevents a recording shadow from reserving its edited global unique ID.
	 */
	public function ignoreShadowsForGlobalUniqueIdUniqueness(
		?bool $unique,
		int $product_id,
		string $global_unique_id
	): ?bool {
		return $this->ignoreShadowsForUniqueValue(
			$unique,
			$product_id,
			'global_unique_id',
			$global_unique_id
		);
	}

	/**
	 * Removes shadow products from WooCommerce SKU owner lookups.
	 */
	public function ignoreShadowSkuOwner( ?int $product_id, string $sku ): int {
		return $this->ignoreShadowUniqueValueOwner( $product_id, 'sku', $sku );
	}

	/**
	 * Removes shadow products from WooCommerce global unique ID owner lookups.
	 */
	public function ignoreShadowGlobalUniqueIdOwner( ?int $product_id, string $global_unique_id ): int {
		return $this->ignoreShadowUniqueValueOwner(
			$product_id,
			'global_unique_id',
			$global_unique_id
		);
	}

	/**
	 * Keeps shadow variations available to WooCommerce's product-editor AJAX queries.
	 *
	 * @param array<string,mixed> $query_args WP_Query arguments.
	 * @param array<string,mixed> $query_vars WooCommerce product query variables.
	 * @return array<string,mixed>
	 */
	public function includeShadowVariationsInAdminQueries( array $query_args, array $query_vars ): array {
		$parent_id          = (int) ( $query_vars['parent'] ?? 0 );
		$type               = $query_vars['type'] ?? '';
		$is_variation_query = 'variation' === $type
			|| ( is_array( $type ) && in_array( 'variation', $type, true ) );

		if ( $parent_id <= 0 || ! $is_variation_query || ! self::isShadow( $parent_id ) ) {
			return $query_args;
		}

		$query_args['post_status'] = array( self::POST_STATUS, self::LEGACY_POST_STATUS, 'private', 'publish' );

		return $query_args;
	}

	/**
	 * Reports the real shadow variation count in the classic product editor.
	 */
	public function shadowVariationCount( int $count, int $product_id ): int {
		if ( ! self::isShadow( $product_id ) ) {
			return $count;
		}

		global $wpdb;

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(ID)
				FROM {$wpdb->posts}
				WHERE post_parent = %d
					AND post_type = %s
					AND post_status IN (%s, %s, %s)",
				$product_id,
				'product_variation',
				self::POST_STATUS,
				'private',
				'publish'
			)
		);
	}

	/**
	 * Immediately hides variations created or saved by WooCommerce AJAX on a shadow parent.
	 *
	 * @param mixed $unused
	 */
	public function protectShadowVariation( int $variation_id, $unused = null ): void {
		unset( $unused );

		$parent_id = (int) wp_get_post_parent_id( $variation_id );

		if ( $parent_id <= 0 || ! self::isShadow( $parent_id ) ) {
			return;
		}

		$session_uuid     = (string) get_post_meta( $parent_id, self::SESSION_META_KEY, true );
		$source_id        = (int) get_post_meta( $parent_id, self::SOURCE_META_KEY, true );
		$source_object_id = (int) get_post_meta( $variation_id, self::SOURCE_OBJECT_META, true );
		$current_status   = (string) get_post_status( $variation_id );

		if ( in_array( $current_status, array( 'publish', 'private' ), true ) ) {
			update_post_meta( $variation_id, self::INTENDED_STATUS_META, $current_status );
		}

		if ( self::POST_STATUS !== $current_status ) {
			wp_update_post(
				array(
					'ID'          => $variation_id,
					'post_status' => self::POST_STATUS,
				)
			);
		}

		self::markShadow(
			$variation_id,
			$session_uuid,
			$source_id,
			$source_object_id,
			'variation'
		);
	}

	/**
	 * Hides shadows from WooCommerce product visibility checks.
	 *
	 * @param bool $visible Current visibility.
	 * @param int  $product_id Product ID.
	 */
	public function hideWooCommerceProduct( bool $visible, int $product_id ): bool {
		return self::isShadow( $product_id ) ? false : $visible;
	}

	/**
	 * Removes shadows from non-admin post query results as a fallback.
	 *
	 * @param list<\WP_Post> $posts Query posts.
	 * @param \WP_Query      $query Query object.
	 * @return list<\WP_Post>
	 */
	public function removeFromPublicQueries( array $posts, \WP_Query $query ): array {
		unset( $query );

		if ( is_admin() || array() === $posts ) {
			return $posts;
		}

		return array_values(
			array_filter(
				$posts,
				static fn ( \WP_Post $post ): bool => ! self::isShadow( (int) $post->ID )
			)
		);
	}

	/**
	 * Removes shadow products from WordPress sitemap entries.
	 *
	 * @param array<string,mixed>|null $entry Sitemap entry.
	 * @param \WP_Post                 $post Post object.
	 * @param string                   $post_type Post type.
	 * @return array<string,mixed>|null
	 */
	public function removeFromSitemaps( ?array $entry, \WP_Post $post, string $post_type ): ?array {
		if ( ! in_array( $post_type, array( 'product', 'product_variation' ), true ) ) {
			return $entry;
		}

		return self::isShadow( (int) $post->ID ) ? null : $entry;
	}

	/**
	 * Blocks unauthenticated direct REST reads of shadow products.
	 *
	 * @param mixed            $result Existing dispatch result.
	 * @param \WP_REST_Server  $server REST server.
	 * @param \WP_REST_Request $request REST request.
	 * @return mixed
	 */
	public function blockPublicRestAccess( $result, \WP_REST_Server $server, \WP_REST_Request $request ) {
		unset( $server );

		if ( null !== $result ) {
			return $result;
		}

		$id = (int) ( $request->get_param( 'id' ) ?? 0 );

		if ( $id <= 0 || ! self::isShadow( $id ) || current_user_can( 'edit_post', $id ) ) {
			return $result;
		}

		$route = $request->get_route();

		if ( strpos( $route, '/product' ) === false ) {
			return $result;
		}

		return new \WP_Error(
			'scheduled_changes_shadow_product_not_public',
			__( 'Recording shadow products are not public.', 'jjs-post-scheduler' ),
			array( 'status' => 404 )
		);
	}

	/**
	 * Converts direct front-end product requests for shadows into a 404.
	 */
	public function blockSingularTemplate(): void {
		if ( ! is_singular( 'product' ) ) {
			return;
		}

		$post_id = (int) get_queried_object_id();

		if ( ! self::isShadow( $post_id ) ) {
			return;
		}

		global $wp_query;

		$wp_query->set_404();
		status_header( 404 );
		nocache_headers();
	}

	private function ignoreShadowsForUniqueValue(
		?bool $unique,
		int $product_id,
		string $column,
		string $value
	): ?bool {
		if ( null !== $unique || '' === $value ) {
			return $unique;
		}

		if ( ! self::isShadow( $product_id ) && ! $this->hasShadowUniqueValueOwner( $column, $value ) ) {
			return null;
		}

		$excluded_ids = array( $product_id );

		if ( self::isShadow( $product_id ) ) {
			$source_object_id = (int) get_post_meta( $product_id, self::SOURCE_OBJECT_META, true );

			if ( $source_object_id > 0 ) {
				$excluded_ids[] = $source_object_id;
			}
		}

		return 0 === $this->realUniqueValueOwnerId( $column, $value, $excluded_ids );
	}

	private function ignoreShadowUniqueValueOwner( ?int $product_id, string $column, string $value ): int {
		if ( null === $product_id || $product_id <= 0 ) {
			return 0;
		}

		if ( ! self::isShadow( $product_id ) ) {
			return $product_id;
		}

		return $this->realUniqueValueOwnerId( $column, $value );
	}

	private function hasShadowUniqueValueOwner( string $column, string $value ): bool {
		global $wpdb;

		$column = $this->uniqueValueColumn( $column );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Column is selected from a fixed allowlist.
		$sql = "SELECT lookup.product_id
			FROM {$wpdb->wc_product_meta_lookup} AS lookup
			INNER JOIN {$wpdb->posts} AS posts ON posts.ID = lookup.product_id
			INNER JOIN {$wpdb->postmeta} AS shadow
				ON shadow.post_id = lookup.product_id
				AND shadow.meta_key = %s
				AND shadow.meta_value = %s
			WHERE posts.post_type IN ('product', 'product_variation')
				AND posts.post_status != 'trash'
				AND lookup.{$column} = %s
			LIMIT 1";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				$sql,
				self::SHADOW_META_KEY,
				'1',
				$value
			)
		) > 0;
	}

	/**
	 * Finds the first non-shadow owner for a WooCommerce unique product value.
	 *
	 * @param int[] $excluded_ids Product or variation IDs to exclude.
	 */
	private function realUniqueValueOwnerId( string $column, string $value, array $excluded_ids = array() ): int {
		global $wpdb;

		$column       = $this->uniqueValueColumn( $column );
		$excluded_ids = array_values( array_unique( array_filter( array_map( 'absint', $excluded_ids ) ) ) );
		$exclusions   = '';
		$parameters   = array(
			self::SHADOW_META_KEY,
			'1',
			$value,
		);

		if ( array() !== $excluded_ids ) {
			$exclusions = ' AND lookup.product_id NOT IN (' . implode( ', ', array_fill( 0, count( $excluded_ids ), '%d' ) ) . ')';
			array_push( $parameters, ...$excluded_ids );
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Column and numeric placeholder list are generated from fixed values.
		$sql = "SELECT lookup.product_id
			FROM {$wpdb->wc_product_meta_lookup} AS lookup
			INNER JOIN {$wpdb->posts} AS posts ON posts.ID = lookup.product_id
			LEFT JOIN {$wpdb->postmeta} AS shadow
				ON shadow.post_id = lookup.product_id
				AND shadow.meta_key = %s
				AND shadow.meta_value = %s
			WHERE posts.post_type IN ('product', 'product_variation')
				AND posts.post_status != 'trash'
				AND shadow.post_id IS NULL
				AND lookup.{$column} = %s{$exclusions}
			ORDER BY lookup.product_id ASC
			LIMIT 1";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		return (int) $wpdb->get_var( $wpdb->prepare( $sql, $parameters ) );
	}

	private function uniqueValueColumn( string $column ): string {
		if ( ! in_array( $column, array( 'sku', 'global_unique_id' ), true ) ) {
			throw new \InvalidArgumentException( 'Unsupported WooCommerce unique-value column.' );
		}

		return $column;
	}
}
