<?php
/**
 * Native WooCommerce product field provider.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChangesWooCommerce\CoreCompat\WooCommerce;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are escaped at UI boundaries.

use ScheduledChanges\Field\FieldCollection;
use ScheduledChanges\Field\FieldDefinition;
use ScheduledChanges\Provider\AbstractFieldProvider;
use ScheduledChanges\Provider\ExecutionContext;
use ScheduledChanges\Provider\ObjectContext;
use ScheduledChanges\Provider\OperationResult;
use ScheduledChanges\Provider\ProviderCategory;
use ScheduledChanges\Provider\ProviderSnapshot;
use ScheduledChanges\Provider\RollbackContext;
use ScheduledChanges\Provider\ValidationResult;
use ScheduledChangesWooCommerce\CoreCompat\Recording\ShadowProductAccessGuard;
use ScheduledChangesWooCommerce\CoreCompat\WooCommerce\Attributes\AttributeCodec;
use ScheduledChangesWooCommerce\CoreCompat\WooCommerce\Downloads\DownloadCodec;
use ScheduledChangesWooCommerce\CoreCompat\WooCommerce\Pricing\PricingModule;
use Throwable;

/**
 * Reads and writes supported product state through WooCommerce CRUD.
 */
final class NativeProductProvider extends AbstractFieldProvider {
	private NativeFieldSchema $schema;
	private ProductTypeCapabilities $capabilities;
	private AttributeCodec $attributes;
	private DownloadCodec $downloads;
	private ProductSynchronizer $synchronizer;
	private PricingModule $pricing;
	public function __construct(
		?NativeFieldSchema $schema = null,
		?ProductTypeCapabilities $capabilities = null,
		?AttributeCodec $attributes = null,
		?DownloadCodec $downloads = null,
		?ProductSynchronizer $synchronizer = null,
		?PricingModule $pricing = null
	) {
		$schema           ??= new NativeFieldSchema();
		$capabilities     ??= new ProductTypeCapabilities();
		$attributes       ??= new AttributeCodec();
		$downloads        ??= new DownloadCodec();
		$synchronizer     ??= new ProductSynchronizer();
		$pricing          ??= new PricingModule();
		$this->schema       = $schema;
		$this->capabilities = $capabilities;
		$this->attributes   = $attributes;
		$this->downloads    = $downloads;
		$this->synchronizer = $synchronizer;
		$this->pricing      = $pricing;
		parent::__construct();
	}

	public function id(): string {
		return 'woocommerce-core';
	}

	public function version(): string {
		return defined( 'WC_VERSION' ) ? WC_VERSION : 'unavailable';
	}

	public function category(): string {
		return ProviderCategory::WOOCOMMERCE_CORE;
	}

	public function supports( ObjectContext $context ): bool {
		return in_array( $context->targetType(), array( 'product', 'variation' ), true )
			&& null !== $context->targetId()
			&& function_exists( 'wc_get_product' );
	}

	public function discover( ObjectContext $context ): FieldCollection {
		return $this->schema->definitions(
			$context,
			$this->product( $context ),
			$this->capabilities
		);
	}

	/**
	 * @return mixed
	 */
	public function read( ObjectContext $context, FieldDefinition $field ) {
		return $this->readFromProduct( $this->product( $context ), $field );
	}

	/**
	 * @param mixed $value
	 */
	public function validate(
		ObjectContext $context,
		FieldDefinition $field,
		$value
	): ValidationResult {
		$base = parent::validate( $context, $field, $value );

		if ( ! $base->isValid() ) {
			return $base;
		}

		$product    = $this->product( $context );
		$key        = $this->schema->keyFor( $field );
		$normalized = $this->normalize( $field, $value );

		try {
			$this->validateField( $product, $key, $normalized );
		} catch ( Throwable $throwable ) {
			return ValidationResult::invalid(
				'native_validation_failed',
				$throwable->getMessage(),
				$field->id()
			);
		}

		return ValidationResult::valid();
	}

	/**
	 * @param mixed $value
	 */
	public function write(
		ObjectContext $context,
		FieldDefinition $field,
		$value,
		ExecutionContext $execution
	): OperationResult {
		if ( ! $field->writable() ) {
			return OperationResult::failure( 'read_only_field', 'The field is read-only.' );
		}

		$validation = $this->validate( $context, $field, $value );

		if ( ! $validation->isValid() ) {
			return OperationResult::failure(
				'validation_failed',
				$validation->errors()[0]['message'],
				array( 'errors' => $validation->errors() )
			);
		}

		$product    = $this->product( $context );
		$key        = $this->schema->keyFor( $field );
		$normalized = $this->normalize( $field, $value );
		$before     = $this->normalize( $field, $this->readFromProduct( $product, $field ) );
		$permission = $this->writePermissionFailure( $key, $before, $normalized, $execution );

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

		if ( $before === $normalized ) {
			return OperationResult::success( 'The canonical value is already applied.', false );
		}

		if ( $execution->dryRun() ) {
			return OperationResult::success( 'Dry run validated the native field write.', true );
		}

		try {
			$this->setOnProduct( $product, $field, $normalized );
			if ( 'core.type' !== $key ) {
				$this->saveProductPreservingPostPassword( $product );
				$this->synchronizer->synchronize( $product );
			}

			$reloaded = wc_get_product( $product->get_id() );

			if ( ! $reloaded instanceof \WC_Product ) {
				throw new \RuntimeException( 'WooCommerce did not reload the saved product.' );
			}

			if ( 'core.type' === $key ) {
				$this->synchronizer->synchronize( $reloaded );
			}

			$after = $this->normalize( $field, $this->readFromProduct( $reloaded, $field ) );

			if ( $after !== $normalized ) {
				$this->restoreFailedWrite( $product->get_id(), $field, $key, $before );

				return OperationResult::failure(
					'verification_failed',
					'WooCommerce readback did not match the requested canonical value.'
				);
			}

			return OperationResult::success(
				'WooCommerce field saved and verified.',
				true,
				array( 'product_id' => $product->get_id() )
			);
		} catch ( Throwable $throwable ) {
			$this->restoreFailedWrite( $product->get_id(), $field, $key, $before );

			return OperationResult::failure(
				'native_write_failed',
				$throwable->getMessage(),
				array( 'product_id' => $product->get_id() )
			);
		}
	}

	/**
	 * Best-effort compensation for a write that failed after mutating WordPress state.
	 *
	 * @param mixed $before Previous normalized value.
	 */
	private function restoreFailedWrite( int $product_id, FieldDefinition $field, string $key, $before ): void {
		try {
			$product = wc_get_product( $product_id );

			if ( ! $product instanceof \WC_Product ) {
				return;
			}

			$this->setOnProduct( $product, $field, $before );

			if ( 'core.type' !== $key ) {
				$this->saveProductPreservingPostPassword( $product );
			}

			$reloaded = wc_get_product( $product_id );

			if ( $reloaded instanceof \WC_Product ) {
				$this->synchronizer->synchronize( $reloaded );
			}
		} catch ( Throwable $compensation_error ) {
			unset( $compensation_error );
		}
	}

	/**
	 * Enforces operation-specific WordPress capabilities for privileged core fields.
	 *
	 * @param mixed $before Current normalized value.
	 * @param mixed $after Requested normalized value.
	 */
	private function writePermissionFailure(
		string $key,
		$before,
		$after,
		ExecutionContext $execution
	): ?OperationResult {
		$actor_id = (int) $execution->actorUserId();

		if ( in_array( $key, array( 'core.status', 'core.author_id' ), true ) && $actor_id <= 0 ) {
			return OperationResult::failure( 'actor_unavailable', 'A valid execution actor is required.' );
		}

		if (
			'core.status' === $key
			&& $before !== $after
			&& in_array( (string) $after, array( 'publish', 'future', 'private' ), true )
			&& ! user_can( $actor_id, 'publish_products' ) // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers product post-type capabilities.
		) {
			return OperationResult::failure( 'publish_permission_denied', 'The execution actor cannot publish products.' );
		}

		if ( 'core.author_id' === $key && (int) $before !== (int) $after ) {
			$author_id = (int) $after;

			if (
				( $author_id !== $actor_id && ! user_can( $actor_id, 'edit_others_products' ) ) // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers product post-type capabilities.
				|| ! user_can( $author_id, 'edit_products' ) // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce registers product post-type capabilities.
			) {
				return OperationResult::failure( 'author_permission_denied', 'The execution actor cannot assign this product author.' );
			}
		}

		return null;
	}

	public function snapshot( ObjectContext $context ): ProviderSnapshot {
		$values = array();

		foreach ( $this->discover( $context ) as $field ) {
			$values[ $field->id() ] = $this->normalize(
				$field,
				$this->read( $context, $field )
			);
		}

		return new ProviderSnapshot( $this->id(), $this->version(), $values );
	}

	public function rollback(
		ObjectContext $context,
		ProviderSnapshot $snapshot,
		RollbackContext $rollback
	): OperationResult {
		unset( $rollback );

		if ( $snapshot->providerId() !== $this->id() ) {
			return OperationResult::failure( 'provider_mismatch', 'Snapshot belongs to another provider.' );
		}

		$product = $this->product( $context );
		$fields  = $this->discover( $context );

		try {
			foreach ( $snapshot->values() as $field_id => $value ) {
				$field = $fields->get( $field_id );

				if ( null === $field || ! $field->writable() || ! $field->reversible() ) {
					continue;
				}

				$this->setOnProduct( $product, $field, $value );
			}

				$this->saveProductPreservingPostPassword( $product );
				$this->synchronizer->synchronize( $product );
			$reloaded = wc_get_product( $product->get_id() );

			if ( ! $reloaded instanceof \WC_Product ) {
				throw new \RuntimeException( 'WooCommerce did not reload the rolled-back product.' );
			}

			foreach ( $snapshot->values() as $field_id => $value ) {
				$field = $fields->get( $field_id );

				if ( null === $field || ! $field->writable() || ! $field->reversible() ) {
					continue;
				}

				$current = $this->normalize( $field, $this->readFromProduct( $reloaded, $field ) );

				if ( $current !== $value ) {
					return OperationResult::failure(
						'rollback_verification_failed',
						'Rollback readback failed for ' . $field_id . '.'
					);
				}
			}

			return OperationResult::success( 'WooCommerce snapshot restored and verified.' );
		} catch ( Throwable $throwable ) {
			return OperationResult::failure( 'rollback_failed', $throwable->getMessage() );
		}
	}

	private function product( ObjectContext $context ): \WC_Product {
		$product = wc_get_product( $context->targetId() );

		if ( ! $product instanceof \WC_Product ) {
			throw new \RuntimeException( 'WooCommerce product not found.' );
		}

		$is_variation = $product instanceof \WC_Product_Variation;

		if ( ( 'variation' === $context->targetType() ) !== $is_variation ) {
			throw new \RuntimeException( 'Object context does not match the WooCommerce product type.' );
		}

		return $product;
	}

	/**
	 * @return mixed
	 */
	private function readFromProduct( \WC_Product $product, FieldDefinition $field ) {
		$key  = $this->schema->keyFor( $field );
		$spec = $this->schema->specFor( $field );

		if ( ShadowProductAccessGuard::isShadow( $product->get_id() ) ) {
			switch ( $key ) {
				case 'core.slug':
					$intended_meta_key = ShadowProductAccessGuard::INTENDED_SLUG_META;
					break;
				case 'core.status':
					$intended_meta_key = ShadowProductAccessGuard::INTENDED_STATUS_META;
					break;
				case 'catalog.visibility':
					$intended_meta_key = ShadowProductAccessGuard::INTENDED_VISIBILITY_META;
					break;
				default:
					$intended_meta_key = '';
					break;
			}

			if (
				'' !== $intended_meta_key
				&& metadata_exists( 'post', $product->get_id(), $intended_meta_key )
			) {
				return get_post_meta( $product->get_id(), $intended_meta_key, true );
			}
		}

		if ( 'core.password' === $key ) {
			return (string) get_post_field( 'post_password', $product->get_id(), 'raw' );
		}

		if ( 'core.author_id' === $key ) {
			return (int) get_post_field( 'post_author', $product->get_id(), 'raw' );
		}

		if ( 'core.publish_date' === $key ) {
			$published = get_post_datetime( $product->get_id(), 'date', 'gmt' );

			return $published instanceof \DateTimeImmutable
				? $published->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d\TH:i:s\Z' )
				: null;
		}

		if ( 'catalog.visible_in_pos' === $key ) {
			return ! has_term( 'pos-hidden', 'pos_product_visibility', $product->get_id() );
		}

		if ( 'attributes.definitions' === $key ) {
			return $this->attributes->encode( $product->get_attributes( 'edit' ) );
		}

		if ( 'downloads.files' === $key ) {
			return $this->downloads->encode( $product->get_downloads( 'edit' ) );
		}

		$getter = (string) $spec['getter'];
		$value  = in_array( $key, array( 'core.type', 'relationships.grouped_children', 'cost_of_goods.value' ), true )
			? $product->{$getter}()
			: $product->{$getter}( 'edit' );

		if ( $value instanceof \WC_DateTime ) {
			return gmdate( 'Y-m-d\TH:i:s\Z', $value->getTimestamp() );
		}

		return $value;
	}

	/**
	 * @param mixed $value
	 */
	private function setOnProduct(
		\WC_Product $product,
		FieldDefinition $field,
		$value
	): void {
		$key  = $this->schema->keyFor( $field );
		$spec = $this->schema->specFor( $field );

		if ( 'core.password' === $key ) {
			$updated = wp_update_post(
				array(
					'ID'            => $product->get_id(),
					'post_password' => (string) $value,
				),
				true
			);

			if ( $updated instanceof \WP_Error ) {
				throw new \RuntimeException( $updated->get_error_message() );
			}

			return;
		}

		if ( 'core.author_id' === $key ) {
			$updated = wp_update_post(
				array(
					'ID'          => $product->get_id(),
					'post_author' => (int) $value,
				),
				true
			);

			if ( $updated instanceof \WP_Error ) {
				throw new \RuntimeException( $updated->get_error_message() );
			}

			return;
		}

		if ( 'core.publish_date' === $key ) {
			$published = new \DateTimeImmutable( (string) $value, new \DateTimeZone( 'UTC' ) );
			$utc       = $published->setTimezone( new \DateTimeZone( 'UTC' ) );
			$updated   = wp_update_post(
				array(
					'ID'            => $product->get_id(),
					'post_date'     => get_date_from_gmt( $utc->format( 'Y-m-d H:i:s' ) ),
					'post_date_gmt' => $utc->format( 'Y-m-d H:i:s' ),
				),
				true
			);

			if ( $updated instanceof \WP_Error ) {
				throw new \RuntimeException( $updated->get_error_message() );
			}

			return;
		}

		if ( 'catalog.visible_in_pos' === $key ) {
			$visible = (bool) $value;

			if (
				class_exists( '\Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSProductVisibilitySync' )
				&& function_exists( 'wc_get_container' )
			) {
				$sync = wc_get_container()->get(
					\Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\POSProductVisibilitySync::class
				);
				$sync->set_product_pos_visibility( $product->get_id(), $visible );
			} else {
				wp_set_object_terms(
					$product->get_id(),
					$visible ? array() : array( 'pos-hidden' ),
					'pos_product_visibility',
					false
				);
			}

			return;
		}

		if ( 'core.type' === $key ) {
			$this->setProductType( $product, (string) $value );

			return;
		}

		if ( 'attributes.definitions' === $key ) {
			$product->set_attributes(
				$this->attributes->decode(
					is_array( $value ) ? $value : array(),
					false
				)
			);

			return;
		}

		if ( 'downloads.files' === $key ) {
			$product->set_downloads(
				$this->downloads->decode( is_array( $value ) ? $value : array() )
			);

			return;
		}

		$setter = $spec['setter'];

		if ( ! is_string( $setter ) || ! method_exists( $product, $setter ) ) {
			throw new \RuntimeException( 'The field is not writable for this product type.' );
		}

		if ( in_array( $key, array( 'pricing.sale_start', 'pricing.sale_end' ), true ) ) {
			$product->{$setter}( null === $value ? null : (string) $value );

			return;
		}

		if ( 'inventory.stock_quantity' === $key && null !== $value && ! $product->get_manage_stock( 'edit' ) ) {
			$product->set_manage_stock( true );
		}

		$normalization = is_array( $spec['normalization'] ) ? $spec['normalization'] : array();
		$write_value   = null === $value
			&& (
				(bool) ( $normalization['empty_is_null'] ?? false )
				|| (bool) ( $normalization['zero_is_null'] ?? false )
			)
				? ''
				: $value;

		if ( 'cost_of_goods.value' === $key ) {
			$write_value = null === $value ? null : (float) $value;
		}

		$product->{$setter}( $write_value );
	}

	private function saveProductPreservingPostPassword( \WC_Product $product ): void {
		$product_id = $product->get_id();
		$password   = (string) get_post_field( 'post_password', $product_id, 'raw' );

		$product->save();

		if ( (string) get_post_field( 'post_password', $product_id, 'raw' ) === $password ) {
			return;
		}

		$updated = wp_update_post(
			array(
				'ID'            => $product_id,
				'post_password' => $password,
			),
			true
		);

		if ( $updated instanceof \WP_Error ) {
			throw new \RuntimeException( $updated->get_error_message() );
		}
	}

	/**
	 * @param mixed $value
	 */
	private function validateField( \WC_Product $product, string $key, $value ): void {
		if ( 0 === strpos( $key, 'pricing.' ) ) {
			$this->pricing->validate( $product, $key, $value );
			return;
		}

		if (
			in_array(
				$key,
				array(
					'cost_of_goods.value',
					'shipping.weight',
					'shipping.dimensions.length',
					'shipping.dimensions.width',
					'shipping.dimensions.height',
				),
				true
			)
			&& null !== $value
			&& (float) $value < 0
		) {
			throw new \InvalidArgumentException( 'Negative decimal values are not allowed.' );
		}

		if ( 'core.type' === $key ) {
			$target_type = sanitize_key( (string) $value );

			if ( $target_type === $product->get_type() ) {
				return;
			}

			if ( ! array_key_exists( $target_type, $this->capabilities->registeredTypes() ) ) {
				throw new \InvalidArgumentException( 'Product type is not registered in WooCommerce.' );
			}

			if ( 'simple' !== $product->get_type() || 'variable' !== $target_type ) {
				throw new \InvalidArgumentException( 'Only simple-to-variable product type conversion is supported while recording.' );
			}
		}

		switch ( $key ) {
			case 'core.status':
				$allowed = array_keys( get_post_stati( array( 'internal' => false ) ) );
				break;
			case 'core.type':
				$allowed = array_keys( $this->capabilities->registeredTypes() );
				break;
			case 'catalog.visibility':
				$allowed = array_keys( wc_get_product_visibility_options() );
				break;
			case 'inventory.stock_status':
				$allowed = array_keys( wc_get_product_stock_status_options() );
				break;
			case 'inventory.backorders':
				$allowed = array_keys( wc_get_product_backorder_options() );
				break;
			case 'tax.status':
				$allowed = array( 'taxable', 'shipping', 'none' );
				break;
			default:
				$allowed = array();
				break;
		}

		if ( array() !== $allowed && ! in_array( $value, $allowed, true ) ) {
			throw new \InvalidArgumentException( 'Value is not allowed for this WooCommerce field.' );
		}

		if ( 'core.author_id' === $key && (int) $value > 0 && ! get_userdata( (int) $value ) instanceof \WP_User ) {
			throw new \InvalidArgumentException( 'Product author does not exist.' );
		}

		if ( 'inventory.sku' === $key && '' !== (string) $value && ! wc_product_has_unique_sku( $product->get_id(), (string) $value ) ) {
			throw new \InvalidArgumentException( 'SKU is already assigned to another product.' );
		}

		if (
			'inventory.global_unique_id' === $key
			&& '' !== (string) $value
			&& function_exists( 'wc_product_has_global_unique_id' )
			&& ! wc_product_has_global_unique_id( $product->get_id(), (string) $value )
		) {
			throw new \InvalidArgumentException( 'Global unique ID is already assigned to another product.' );
		}

		if ( 'media.featured_image_id' === $key && (int) $value > 0 && ! wp_attachment_is_image( (int) $value ) ) {
			throw new \InvalidArgumentException( 'Featured media must be an existing image attachment.' );
		}

		if ( 'media.gallery_image_ids' === $key ) {
			foreach ( is_array( $value ) ? $value : array() as $image_id ) {
				if ( ! wp_attachment_is_image( (int) $image_id ) ) {
					throw new \InvalidArgumentException( 'Gallery media must contain existing image attachments.' );
				}
			}
		}

		if (
			in_array(
				$key,
				array(
					'relationships.upsell_ids',
					'relationships.cross_sell_ids',
					'relationships.grouped_children',
				),
				true
			)
		) {
			foreach ( is_array( $value ) ? $value : array() as $product_id ) {
				if ( ! wc_get_product( (int) $product_id ) ) {
					throw new \InvalidArgumentException( 'Related product does not exist.' );
				}
			}
		}

		if ( 'attributes.definitions' === $key ) {
			$this->attributes->decode( is_array( $value ) ? $value : array(), false );
		}

		if ( 'downloads.files' === $key ) {
			$this->downloads->decode( is_array( $value ) ? $value : array() );
		}
	}

	private function setProductType( \WC_Product $product, string $target_type ): void {
		$target_type = sanitize_key( $target_type );

		if ( '' === $target_type || $target_type === $product->get_type() ) {
			return;
		}

		$result = wp_set_object_terms(
			$product->get_id(),
			$target_type,
			'product_type',
			false
		);

		if ( is_wp_error( $result ) ) {
			throw new \RuntimeException( $result->get_error_message() );
		}

		clean_post_cache( $product->get_id() );
		clean_object_term_cache( $product->get_id(), 'product' );
		wc_delete_product_transients( $product->get_id() );
		$this->clearProductTypeCaches( $product->get_id() );

		if ( 'variable' === $target_type ) {
			\WC_Product_Variable::sync( $product->get_id() );
		}

		$reloaded = wc_get_product( $product->get_id() );

		if ( ! $reloaded instanceof \WC_Product || $target_type !== $reloaded->get_type() ) {
			throw new \RuntimeException( 'WooCommerce did not persist the requested product type.' );
		}
	}

	private function clearProductTypeCaches( int $product_id ): void {
		if ( class_exists( '\WC_Cache_Helper' ) ) {
			$cache_key = \WC_Cache_Helper::get_cache_prefix( 'product_' . $product_id ) . '_type_' . $product_id;
			wp_cache_delete( $cache_key, 'products' );
			\WC_Cache_Helper::invalidate_cache_group( 'product_' . $product_id );
		}

		if (
			class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' )
			&& \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_instance_caching' )
			&& function_exists( 'wc_get_container' )
			&& class_exists( '\Automattic\WooCommerce\Internal\Caches\ProductCache' )
		) {
			wc_get_container()
				->get( \Automattic\WooCommerce\Internal\Caches\ProductCache::class )
				->remove( $product_id );
		}
	}
}
