<?php
/**
 * WooCommerce variation lifecycle service.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChangesWooCommerce\CoreCompat\WooCommerce\Variations;

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

use DateTimeInterface;
use ScheduledChanges\Support\Json;
use ScheduledChangesWooCommerce\CoreCompat\WooCommerce\Downloads\DownloadCodec;
use ScheduledChangesWooCommerce\CoreCompat\WooCommerce\ProductSynchronizer;
use RuntimeException;

/**
 * Creates and mutates variations through public CRUD with stable idempotency.
 */
final class VariationService {
	private ProductSynchronizer $synchronizer;
	private DownloadCodec $downloads;
	private const OPERATION_META    = '_scheduled_changes_creation_operation_uuid';
	private const REQUEST_HASH_META = '_scheduled_changes_creation_request_hash';

	public function __construct( ?ProductSynchronizer $synchronizer = null, ?DownloadCodec $downloads = null ) {
		$synchronizer     ??= new ProductSynchronizer();
		$downloads        ??= new DownloadCodec();
		$this->synchronizer = $synchronizer;
		$this->downloads    = $downloads;
	}

	public function createOrGet( int $parent_id, VariationRequest $request ): VariationCreationResult {
		$parent       = $this->variableParent( $parent_id );
		$status       = $parent->get_status( 'edit' );
		$request_hash = $this->requestHash( $request );

		$existing = $this->findByOperationUuid( $parent, $request->operationUuid()->value() );

		if ( null !== $existing ) {
			$stored_hash = (string) $existing->get_meta( self::REQUEST_HASH_META, true, 'edit' );

			if ( '' === $stored_hash || ! hash_equals( $stored_hash, $request_hash ) ) {
				throw new RuntimeException( 'Variation operation UUID was already used with a different request.' );
			}

			return new VariationCreationResult( $existing->get_id(), false );
		}

		$attributes = $this->normalizeAttributes( $request->attributes() );
		$this->validateAttributes( $parent, $attributes );

		$duplicate = $this->findByAttributes( $parent, $attributes );

		if ( null !== $duplicate ) {
			throw new RuntimeException( 'A variation already uses the requested attribute combination.' );
		}

		$variation = new \WC_Product_Variation();
		$variation->set_parent_id( $parent_id );
		$variation->set_attributes( $attributes );
		$variation->set_status( 'publish' );
		$this->applyValues( $variation, $request->values() );
		$variation->add_meta_data( self::OPERATION_META, $request->operationUuid()->value(), true );
		$variation->add_meta_data( self::REQUEST_HASH_META, $request_hash, true );
		$variation_id = $variation->save();

		if ( $variation_id <= 0 ) {
			throw new RuntimeException( 'WooCommerce did not create the variation.' );
		}

		$this->synchronizer->synchronize( $variation );
		$this->preserveParentStatus( $parent_id, $status );

		return new VariationCreationResult( $variation_id, true );
	}

	public function cloneVariation(
		int $parent_id,
		int $source_variation_id,
		VariationRequest $request
	): VariationCreationResult {
		$source = wc_get_product( $source_variation_id );

		if ( ! $source instanceof \WC_Product_Variation || $source->get_parent_id() !== $parent_id ) {
			throw new RuntimeException( 'Source variation does not belong to the requested parent.' );
		}

		$values = array_merge(
			array(
				'regular_price'     => $source->get_regular_price( 'edit' ),
				'sale_price'        => $source->get_sale_price( 'edit' ),
				'sale_start'        => $source->get_date_on_sale_from( 'edit' ),
				'sale_end'          => $source->get_date_on_sale_to( 'edit' ),
				'manage_stock'      => $source->get_manage_stock( 'edit' ),
				'stock_quantity'    => $source->get_stock_quantity( 'edit' ),
				'stock_status'      => $source->get_stock_status( 'edit' ),
				'backorders'        => $source->get_backorders( 'edit' ),
				'weight'            => $source->get_weight( 'edit' ),
				'length'            => $source->get_length( 'edit' ),
				'width'             => $source->get_width( 'edit' ),
				'height'            => $source->get_height( 'edit' ),
				'shipping_class_id' => $source->get_shipping_class_id( 'edit' ),
				'tax_class'         => $source->get_tax_class( 'edit' ),
				'image_id'          => $source->get_image_id( 'edit' ),
				'downloadable'      => $source->get_downloadable( 'edit' ),
				'virtual'           => $source->get_virtual( 'edit' ),
				'downloads'         => $this->downloads->encode( $source->get_downloads( 'edit' ) ),
				'download_limit'    => $source->get_download_limit( 'edit' ),
				'download_expiry'   => $source->get_download_expiry( 'edit' ),
				'description'       => $source->get_description( 'edit' ),
				'purchase_note'     => $source->get_purchase_note( 'edit' ),
				'menu_order'        => $source->get_menu_order(),
			),
			$request->values()
		);

		return $this->createOrGet(
			$parent_id,
			new VariationRequest( $request->operationUuid(), $request->attributes(), $values )
		);
	}

	/**
	 * @param array<string,string>|null $attributes New attributes.
	 * @param array<string,mixed>       $values New CRUD values.
	 */
	public function update( int $variation_id, ?array $attributes, array $values ): void {
		$variation = $this->variation( $variation_id );
		$parent_id = $variation->get_parent_id();
		$parent    = $this->variableParent( $parent_id );
		$status    = $parent->get_status( 'edit' );

		if ( null !== $attributes ) {
			$normalized = $this->normalizeAttributes( $attributes );
			$this->validateAttributes( $parent, $normalized );
			$duplicate = $this->findByAttributes( $parent, $normalized, $variation_id );

			if ( null !== $duplicate ) {
				throw new RuntimeException( 'A variation already uses the requested attribute combination.' );
			}

			$variation->set_attributes( $normalized );
		}

		$this->applyValues( $variation, $values );
		$variation->save();
		$this->synchronizer->synchronize( $variation );
		$this->preserveParentStatus( $parent_id, $status );
	}

	public function setEnabled( int $variation_id, bool $enabled ): void {
		$variation = $this->variation( $variation_id );
		$this->update(
			$variation_id,
			null,
			array( 'status' => $enabled ? 'publish' : 'private' )
		);
	}

	public function reorder( int $variation_id, int $menu_order ): void {
		if ( $menu_order < 0 ) {
			throw new RuntimeException( 'Variation menu order cannot be negative.' );
		}

		$this->update( $variation_id, null, array( 'menu_order' => $menu_order ) );
	}

	public function trash( int $variation_id ): void {
		$variation = $this->variation( $variation_id );
		$parent_id = $variation->get_parent_id();
		$parent    = $this->variableParent( $parent_id );
		$status    = $parent->get_status( 'edit' );

		if ( false === wp_trash_post( $variation_id ) ) {
			throw new RuntimeException( 'WordPress could not trash the variation.' );
		}

		$this->synchronizer->synchronize( $parent );
		$this->preserveParentStatus( $parent_id, $status );
	}

	public function deletePermanently( int $variation_id, bool $confirmed ): void {
		if ( ! $confirmed ) {
			throw new RuntimeException( 'Permanent variation deletion requires explicit confirmation.' );
		}

		$variation = $this->variation( $variation_id );
		$parent_id = $variation->get_parent_id();
		$parent    = $this->variableParent( $parent_id );
		$status    = $parent->get_status( 'edit' );
		$deleted   = $variation->delete( true );

		if ( false === $deleted ) {
			throw new RuntimeException( 'WooCommerce could not permanently delete the variation.' );
		}

		$this->synchronizer->synchronize( $parent );
		$this->preserveParentStatus( $parent_id, $status );
	}

	private function variableParent( int $parent_id ): \WC_Product_Variable {
		$parent = wc_get_product( $parent_id );

		if ( ! $parent instanceof \WC_Product_Variable ) {
			throw new RuntimeException( 'Variation parent must be a variable WooCommerce product.' );
		}

		return $parent;
	}

	private function variation( int $variation_id ): \WC_Product_Variation {
		$variation = wc_get_product( $variation_id );

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

		return $variation;
	}

	private function findByOperationUuid(
		\WC_Product_Variable $parent_product,
		string $operation_uuid
	): ?\WC_Product_Variation {
		foreach ( $parent_product->get_children() as $child_id ) {
			$variation = wc_get_product( (int) $child_id );

			if (
				$variation instanceof \WC_Product_Variation
				&& $operation_uuid === $variation->get_meta( self::OPERATION_META, true, 'edit' )
			) {
				return $variation;
			}
		}

		return null;
	}

	/**
	 * @param array<string,string> $attributes Canonical attributes.
	 */
	private function findByAttributes(
		\WC_Product_Variable $parent_product,
		array $attributes,
		int $exclude_variation_id = 0
	): ?\WC_Product_Variation {
		$signature = $this->signature( $attributes );

		foreach ( $parent_product->get_children() as $child_id ) {
			if ( (int) $child_id === $exclude_variation_id ) {
				continue;
			}

			$variation = wc_get_product( (int) $child_id );

			if (
				$variation instanceof \WC_Product_Variation
				&& $signature === $this->signature( $variation->get_attributes( 'edit' ) )
			) {
				return $variation;
			}
		}

		return null;
	}

	/**
	 * @param array<string,mixed> $attributes Variation attributes.
	 * @return array<string,string>
	 */
	private function normalizeAttributes( array $attributes ): array {
		$normalized = array();

		foreach ( $attributes as $name => $value ) {
			$key                = sanitize_title( str_replace( 'attribute_', '', $name ) );
			$normalized[ $key ] = sanitize_title( (string) $value );
		}

		ksort( $normalized );

		return $normalized;
	}

	/**
	 * @param array<string,string> $attributes Variation attributes.
	 */
	private function validateAttributes( \WC_Product_Variable $parent_product, array $attributes ): void {
		$available = array();

		foreach ( $parent_product->get_attributes( 'edit' ) as $attribute ) {
			if ( ! $attribute instanceof \WC_Product_Attribute || ! $attribute->get_variation() ) {
				continue;
			}

			$name               = sanitize_title( $attribute->get_name() );
			$options            = $attribute->is_taxonomy()
				? $attribute->get_slugs()
				: $attribute->get_options();
			$available[ $name ] = array_values(
				array_map(
					static fn ( $option ): string => sanitize_title( (string) $option ),
					$options
				)
			);
		}

		foreach ( $attributes as $name => $value ) {
			if ( ! array_key_exists( $name, $available ) ) {
				throw new RuntimeException( 'Variation attribute is not enabled on the parent: ' . $name );
			}

			if ( '' !== $value && ! in_array( $value, $available[ $name ], true ) ) {
				throw new RuntimeException( 'Variation attribute option is not available on the parent: ' . $name );
			}
		}
	}

	/**
	 * @param array<string,mixed> $attributes Variation attributes.
	 */
	private function signature( array $attributes ): string {
		$normalized = $this->normalizeAttributes( $attributes );
		$json       = wp_json_encode( $normalized );

		return hash( 'sha256', false === $json ? '' : $json );
	}

	private function requestHash( VariationRequest $request ): string {
		return hash(
			'sha256',
			Json::encode(
				array(
					'attributes' => $this->normalizeAttributes( $request->attributes() ),
					'values'     => $this->canonicalize( $request->values() ),
				)
			)
		);
	}

	/**
	 * @param mixed $value
	 * @return mixed
	 */
	private function canonicalize( $value ) {
		if ( $value instanceof DateTimeInterface ) {
			return $value->format( DateTimeInterface::ATOM );
		}

		if ( ! is_array( $value ) ) {
			return $value;
		}

		if ( \ScheduledChanges\Support\PhpCompat::isList( $value ) ) {
			return array_map( array( $this, 'canonicalize' ), $value );
		}

		ksort( $value );

		foreach ( $value as $key => $item ) {
			$value[ $key ] = $this->canonicalize( $item );
		}

		return $value;
	}

	/**
	 * @param array<string,mixed> $values Variation values keyed by CRUD property.
	 */
	private function applyValues( \WC_Product_Variation $variation, array $values ): void {
		$setters = array(
			'status'            => 'set_status',
			'regular_price'     => 'set_regular_price',
			'sale_price'        => 'set_sale_price',
			'sale_start'        => 'set_date_on_sale_from',
			'sale_end'          => 'set_date_on_sale_to',
			'sku'               => 'set_sku',
			'global_unique_id'  => 'set_global_unique_id',
			'manage_stock'      => 'set_manage_stock',
			'stock_quantity'    => 'set_stock_quantity',
			'stock_status'      => 'set_stock_status',
			'backorders'        => 'set_backorders',
			'weight'            => 'set_weight',
			'length'            => 'set_length',
			'width'             => 'set_width',
			'height'            => 'set_height',
			'shipping_class_id' => 'set_shipping_class_id',
			'tax_class'         => 'set_tax_class',
			'image_id'          => 'set_image_id',
			'downloadable'      => 'set_downloadable',
			'virtual'           => 'set_virtual',
			'download_limit'    => 'set_download_limit',
			'download_expiry'   => 'set_download_expiry',
			'description'       => 'set_description',
			'purchase_note'     => 'set_purchase_note',
			'menu_order'        => 'set_menu_order',
		);

		foreach ( $values as $key => $value ) {
			if ( 'downloads' === $key ) {
				$variation->set_downloads(
					$this->downloads->decode( is_array( $value ) ? $value : array() )
				);
				continue;
			}

			if ( ! isset( $setters[ $key ] ) ) {
				throw new RuntimeException( 'Unsupported variation value: ' . $key );
			}

			$setter = $setters[ $key ];

			if ( ! method_exists( $variation, $setter ) ) {
				if ( 'global_unique_id' === $key ) {
					continue;
				}

				throw new RuntimeException( 'Unsupported variation value: ' . $key );
			}

			$variation->{$setter}( $value );
		}
	}

	private function preserveParentStatus( int $parent_id, string $expected_status ): void {
		$parent = wc_get_product( $parent_id );

		if ( ! $parent instanceof \WC_Product_Variable ) {
			throw new RuntimeException( 'Variable parent disappeared during synchronization.' );
		}

		if ( $expected_status !== $parent->get_status( 'edit' ) ) {
			$parent->set_status( $expected_status );
			$parent->save();
		}
	}
}
