<?php
/**
 * Native WooCommerce field schema.
 *
 * @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\FieldDataType;
use ScheduledChanges\Field\FieldDefinition;
use ScheduledChanges\Provider\ObjectContext;

/**
 * Builds context-specific definitions from WooCommerce CRUD properties.
 */
final class NativeFieldSchema {
	/** @var array<string,true>|null */
	private ?array $included_keys;

	/**
	 * Creates an exact field schema or the complete development schema.
	 *
	 * @param string[]|null $included_keys Exact field keys, or null for the complete development schema.
	 */
	public function __construct( ?array $included_keys = null ) {
		$this->included_keys = null === $included_keys ? null : array_fill_keys( $included_keys, true );
	}

	/**
	 * @return array<string,array<string,mixed>>
	 */
	public function specs(): array {
		$specs = array(
			'core.name'                      => $this->spec( 'Name', 'get_name', 'set_name', FieldDataType::STRING, 'text', false ),
			'core.slug'                      => $this->spec( 'Slug', 'get_slug', 'set_slug', FieldDataType::STRING, 'text', false ),
			'core.status'                    => $this->spec( 'Status', 'get_status', 'set_status', FieldDataType::STRING, 'select', true ),
			'core.password'                  => $this->spec( 'Password', 'scheduled_changes_get_post_password', 'scheduled_changes_set_post_password', FieldDataType::STRING, 'password', false, null, array(), true, true ),
			'core.author_id'                 => $this->spec( 'Author', 'scheduled_changes_get_post_author', 'scheduled_changes_set_post_author', FieldDataType::ID, 'user-select', false, null, array(), false, true ),
			'core.publish_date'              => $this->spec( 'Publish date', 'scheduled_changes_get_post_date', 'scheduled_changes_set_post_date', FieldDataType::DATETIME, 'datetime', false, null, array(), false, true ),
			'core.type'                      => $this->spec( 'Product type', 'get_type', 'scheduled_changes_set_product_type', FieldDataType::STRING, 'select', false, null, array(), false, true, false ),
			'catalog.visibility'             => $this->spec( 'Catalog visibility', 'get_catalog_visibility', 'set_catalog_visibility', FieldDataType::STRING, 'select', false ),
			'catalog.featured'               => $this->spec( 'Featured', 'get_featured', 'set_featured', FieldDataType::BOOLEAN, 'toggle', false ),
			'catalog.visible_in_pos'         => $this->spec( 'Visible in POS', 'scheduled_changes_get_visible_in_pos', 'scheduled_changes_set_visible_in_pos', FieldDataType::BOOLEAN, 'toggle', false, 'pos_visibility', array(), false, true ),
			'content.description'            => $this->spec( 'Description', 'get_description', 'set_description', FieldDataType::HTML, 'rich-text', true ),
			'content.short_description'      => $this->spec( 'Short description', 'get_short_description', 'set_short_description', FieldDataType::HTML, 'rich-text', false ),
			'core.menu_order'                => $this->spec( 'Menu order', 'get_menu_order', 'set_menu_order', FieldDataType::INTEGER, 'number', true ),
			'core.reviews_allowed'           => $this->spec( 'Reviews allowed', 'get_reviews_allowed', 'set_reviews_allowed', FieldDataType::BOOLEAN, 'toggle', false ),
			'core.purchase_note'             => $this->spec( 'Purchase note', 'get_purchase_note', 'set_purchase_note', FieldDataType::STRING, 'textarea', true ),
			'pricing.regular_price'          => $this->spec( 'Regular price', 'get_regular_price', 'set_regular_price', FieldDataType::DECIMAL, 'price', true, 'pricing', array( 'empty_is_null' => true ) ),
			'pricing.sale_price'             => $this->spec( 'Sale price', 'get_sale_price', 'set_sale_price', FieldDataType::DECIMAL, 'price', true, 'pricing', array( 'empty_is_null' => true ) ),
			'pricing.sale_start'             => $this->spec( 'Sale start', 'get_date_on_sale_from', 'set_date_on_sale_from', FieldDataType::DATETIME, 'datetime', true, 'pricing' ),
			'pricing.sale_end'               => $this->spec( 'Sale end', 'get_date_on_sale_to', 'set_date_on_sale_to', FieldDataType::DATETIME, 'datetime', true, 'pricing' ),
			'pricing.active_price'           => $this->spec( 'Active price', 'get_price', null, FieldDataType::DECIMAL, 'price', true, 'pricing', array( 'empty_is_null' => true ) ),
			'inventory.sku'                  => $this->spec( 'SKU', 'get_sku', 'set_sku', FieldDataType::STRING, 'text', true, 'inventory' ),
			'inventory.global_unique_id'     => $this->spec( 'GTIN, UPC, EAN, or ISBN', 'get_global_unique_id', 'set_global_unique_id', FieldDataType::STRING, 'text', true, 'inventory' ),
			'inventory.manage_stock'         => $this->spec( 'Manage stock', 'get_manage_stock', 'set_manage_stock', FieldDataType::BOOLEAN, 'toggle', true, 'inventory' ),
			'inventory.stock_quantity'       => $this->withDependencies(
				$this->spec( 'Stock quantity', 'get_stock_quantity', 'set_stock_quantity', FieldDataType::INTEGER, 'number', true, 'inventory' ),
				array( 'inventory.manage_stock' )
			),
			'inventory.stock_status'         => $this->withDependencies(
				$this->spec( 'Stock status', 'get_stock_status', 'set_stock_status', FieldDataType::STRING, 'select', true, 'inventory' ),
				array( 'inventory.manage_stock', 'inventory.stock_quantity' )
			),
			'inventory.backorders'           => $this->withDependencies(
				$this->spec( 'Backorders', 'get_backorders', 'set_backorders', FieldDataType::STRING, 'select', true, 'inventory' ),
				array( 'inventory.manage_stock' )
			),
			'inventory.sold_individually'    => $this->spec( 'Sold individually', 'get_sold_individually', 'set_sold_individually', FieldDataType::BOOLEAN, 'toggle', true, 'inventory' ),
			'inventory.low_stock_threshold'  => $this->withDependencies(
				$this->spec( 'Low-stock threshold', 'get_low_stock_amount', 'set_low_stock_amount', FieldDataType::INTEGER, 'number', true, 'inventory', array( 'empty_is_null' => true ) ),
				array( 'inventory.manage_stock' )
			),
			'shipping.weight'                => $this->spec( 'Weight', 'get_weight', 'set_weight', FieldDataType::DECIMAL, 'number', true, 'shipping', array( 'empty_is_null' => true ) ),
			'shipping.dimensions.length'     => $this->spec( 'Length', 'get_length', 'set_length', FieldDataType::DECIMAL, 'number', true, 'shipping', array( 'empty_is_null' => true ) ),
			'shipping.dimensions.width'      => $this->spec( 'Width', 'get_width', 'set_width', FieldDataType::DECIMAL, 'number', true, 'shipping', array( 'empty_is_null' => true ) ),
			'shipping.dimensions.height'     => $this->spec( 'Height', 'get_height', 'set_height', FieldDataType::DECIMAL, 'number', true, 'shipping', array( 'empty_is_null' => true ) ),
			'shipping.class_id'              => $this->spec( 'Shipping class', 'get_shipping_class_id', 'set_shipping_class_id', FieldDataType::ID, 'select', true, 'shipping' ),
			'tax.status'                     => $this->spec( 'Tax status', 'get_tax_status', 'set_tax_status', FieldDataType::STRING, 'select', true ),
			'tax.class'                      => $this->spec( 'Tax class', 'get_tax_class', 'set_tax_class', FieldDataType::STRING, 'select', true ),
			'media.featured_image_id'        => $this->spec( 'Featured image', 'get_image_id', 'set_image_id', FieldDataType::ID, 'media', true, null, array( 'zero_is_null' => true ) ),
			'media.gallery_image_ids'        => $this->spec( 'Gallery', 'get_gallery_image_ids', 'set_gallery_image_ids', FieldDataType::ID_LIST, 'media-gallery', false, null, array( 'ordered' => true ) ),
			'relationships.upsell_ids'       => $this->spec( 'Upsells', 'get_upsell_ids', 'set_upsell_ids', FieldDataType::ID_LIST, 'product-search', false, null, array( 'ordered' => false ) ),
			'relationships.cross_sell_ids'   => $this->spec( 'Cross-sells', 'get_cross_sell_ids', 'set_cross_sell_ids', FieldDataType::ID_LIST, 'product-search', false, null, array( 'ordered' => false ) ),
			'relationships.grouped_children' => $this->spec( 'Grouped products', 'get_children', 'set_children', FieldDataType::ID_LIST, 'product-search', false, 'grouped_children', array( 'ordered' => true ) ),
			'relationships.parent_id'        => $this->spec( 'Parent product', 'get_parent_id', null, FieldDataType::ID, 'product-search', true ),
			'downloads.downloadable'         => $this->spec( 'Downloadable', 'get_downloadable', 'set_downloadable', FieldDataType::BOOLEAN, 'toggle', true, 'downloads' ),
			'downloads.virtual'              => $this->spec( 'Virtual', 'get_virtual', 'set_virtual', FieldDataType::BOOLEAN, 'toggle', true ),
			'downloads.files'                => $this->spec( 'Download files', 'get_downloads', 'set_downloads', FieldDataType::ARRAY, 'downloads', true, 'downloads', array(), true ),
			'downloads.limit'                => $this->spec( 'Download limit', 'get_download_limit', 'set_download_limit', FieldDataType::INTEGER, 'number', true, 'downloads' ),
			'downloads.expiry'               => $this->spec( 'Download expiry', 'get_download_expiry', 'set_download_expiry', FieldDataType::INTEGER, 'number', true, 'downloads' ),
			'cost_of_goods.value'            => $this->spec( 'Cost of goods', 'get_cogs_value', 'set_cogs_value', FieldDataType::DECIMAL, 'price', true, 'cogs', array( 'empty_is_null' => true ) ),
			'external.product_url'           => $this->spec( 'Product URL', 'get_product_url', 'set_product_url', FieldDataType::STRING, 'url', false, 'external' ),
			'external.button_text'           => $this->spec( 'Button text', 'get_button_text', 'set_button_text', FieldDataType::STRING, 'text', false, 'external' ),
			'attributes.definitions'         => $this->spec( 'Attributes', 'get_attributes', 'set_attributes', FieldDataType::ARRAY, 'attributes', false, 'attributes' ),
			'attributes.default_values'      => $this->spec( 'Default attributes', 'get_default_attributes', 'set_default_attributes', FieldDataType::OBJECT, 'attributes-defaults', false, 'default_attributes' ),
			'attributes.assignment'          => $this->spec( 'Variation attributes', 'get_attributes', 'set_attributes', FieldDataType::OBJECT, 'variation-attributes', true, 'variation' ),
		);

		return null === $this->included_keys ? $specs : array_intersect_key( $specs, $this->included_keys );
	}

	/** Builds field definitions for one product context. */
	public function definitions(
		ObjectContext $context,
		\WC_Product $product,
		ProductTypeCapabilities $capabilities
	): FieldCollection {
		$variation = $product instanceof \WC_Product_Variation;
		$prefix    = $variation ? 'variation.{stable-key}.' : 'product.';
		$fields    = array();

		foreach ( $this->specs() as $key => $spec ) {
			if ( ! $this->isRelevant( $key, $spec, $product, $capabilities ) ) {
				continue;
			}

			$getter    = (string) $spec['getter'];
			$setter    = $spec['setter'];
			$synthetic = (bool) ( $spec['synthetic'] ?? false );

			if ( ! $synthetic && ! method_exists( $product, $getter ) ) {
				continue;
			}

			$writable   = is_string( $setter ) && ( $synthetic || method_exists( $product, $setter ) );
			$reversible = array_key_exists( 'reversible', $spec ) && null !== $spec['reversible']
				? (bool) $spec['reversible']
				: $writable;
			$fields[]   = new FieldDefinition(
				$prefix . $key,
				(string) $spec['label'],
				'WooCommerce native CRUD field.',
				'woocommerce',
				'woocommerce-core',
				array( $context->targetType() ),
				array( $product->get_type() ),
				$spec['type'],
				(string) $spec['ui'],
				true,
				$writable,
				$writable,
				$reversible,
				(bool) $spec['sensitive'],
				array_map(
					static fn ( string $dependency ): string => $prefix . $dependency,
					is_array( $spec['dependencies'] ?? null ) ? $spec['dependencies'] : array()
				),
				'fail',
				array(),
				'edit_products',
				array( 'woocommerce_product_sync' ),
				array( 'product_transients', 'object_cache' ),
				$spec['normalization']
			);
		}

		return new FieldCollection( $fields );
	}

	/**
	 * @return array<string,mixed>
	 */
	public function specFor( FieldDefinition $field ): array {
		$id  = preg_replace( '/^(product|variation\.\{stable-key\})\./', '', $field->id() );
		$key = is_string( $id ) ? $id : '';

		if ( ! isset( $this->specs()[ $key ] ) ) {
			throw new \InvalidArgumentException( 'Unknown native WooCommerce field: ' . $field->id() );
		}

		return $this->specs()[ $key ];
	}

	/** Returns the schema key for a canonical field. */
	public function keyFor( FieldDefinition $field ): string {
		$key = preg_replace( '/^(product|variation\.\{stable-key\})\./', '', $field->id() );

		return is_string( $key ) ? $key : '';
	}

	/**
	 * @param array<string,mixed> $spec Field specification.
	 */
	private function isRelevant(
		string $key,
		array $spec,
		\WC_Product $product,
		ProductTypeCapabilities $capabilities
	): bool {
		$variation = $product instanceof \WC_Product_Variation;

		if ( 'attributes.assignment' === $key ) {
			return $variation;
		}

		if ( $variation && ! (bool) $spec['variation'] ) {
			return false;
		}

		if ( ! $variation && 'relationships.parent_id' === $key ) {
			return false;
		}

		$capability = $spec['capability'];

		return ! is_string( $capability ) || $capabilities->supports( $product, $capability );
	}

	/**
	 * @param array<string,mixed> $normalization Normalization options.
	 * @return array<string,mixed>
	 */
	private function spec(
		string $label,
		string $getter,
		?string $setter,
		string $type,
		string $ui,
		bool $variation,
		?string $capability = null,
		array $normalization = array(),
		bool $sensitive = false,
		bool $synthetic = false,
		?bool $reversible = null
	): array {
		return array(
			'label'         => $label,
			'getter'        => $getter,
			'setter'        => $setter,
			'type'          => $type,
			'ui'            => $ui,
			'variation'     => $variation,
			'capability'    => $capability,
			'normalization' => $normalization,
			'sensitive'     => $sensitive,
			'synthetic'     => $synthetic,
			'reversible'    => $reversible,
			'dependencies'  => array(),
		);
	}

	/**
	 * @param array<string,mixed> $spec Field specification.
	 * @param string[]            $dependencies Relative native field paths.
	 * @return array<string,mixed>
	 */
	private function withDependencies( array $spec, array $dependencies ): array {
		$spec['dependencies'] = $dependencies;

		return $spec;
	}
}
