<?php
/**
 * Storage + sanitisation for a category's smart rule tree (kept in term meta).
 *
 * @package SmartCategories
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class SCWC_Term_Rules
 */
class SCWC_Term_Rules {

	const META_RULES   = '_scwc_rules';
	const META_ENABLED = '_scwc_enabled';
	const META_SORT    = '_scwc_sort';
	const TAXONOMY     = 'product_cat';
	const MAX_DEPTH    = 10;

	/**
	 * Taxonomies whose terms may carry smart rules. Categories are the primary
	 * target; tags enable the hierarchy generator's "create as tags" mode.
	 *
	 * @return string[]
	 */
	public static function taxonomies() {
		/**
		 * Filter the taxonomies that can be smart-rule targets.
		 *
		 * @param string[] $taxonomies Taxonomy names.
		 */
		return apply_filters( 'scwc_smart_taxonomies', array( 'product_cat', 'product_tag' ) );
	}

	/**
	 * Allowed sort orderby keys.
	 *
	 * @return string[]
	 */
	public static function sort_orderby_options() {
		return array( 'default', 'best_selling', 'newest', 'oldest', 'price_asc', 'price_desc', 'rating', 'title' );
	}

	/**
	 * Get the category's storefront sort/OOS config.
	 *
	 * @param int $term_id Term ID.
	 * @return array{orderby:string,oos:string}
	 */
	public static function get_sort( $term_id ) {
		$cfg = get_term_meta( (int) $term_id, self::META_SORT, true );
		if ( ! is_array( $cfg ) ) {
			$cfg = array();
		}
		return array(
			'orderby' => isset( $cfg['orderby'] ) && in_array( $cfg['orderby'], self::sort_orderby_options(), true ) ? $cfg['orderby'] : 'default',
			'oos'     => isset( $cfg['oos'] ) && in_array( $cfg['oos'], array( 'none', 'push_down', 'hide' ), true ) ? $cfg['oos'] : 'none',
		);
	}

	/**
	 * Save the category's storefront sort/OOS config.
	 *
	 * @param int   $term_id Term ID.
	 * @param array $cfg     Config.
	 * @return void
	 */
	public static function save_sort( $term_id, $cfg ) {
		$orderby = isset( $cfg['orderby'] ) && in_array( $cfg['orderby'], self::sort_orderby_options(), true ) ? $cfg['orderby'] : 'default';
		$oos     = isset( $cfg['oos'] ) && in_array( $cfg['oos'], array( 'none', 'push_down', 'hide' ), true ) ? $cfg['oos'] : 'none';
		update_term_meta(
			(int) $term_id,
			self::META_SORT,
			array(
				'orderby' => $orderby,
				'oos'     => $oos,
			)
		);
	}

	/**
	 * Default (empty) rule tree.
	 *
	 * @return array
	 */
	public static function default_tree() {
		return array(
			'type'     => 'group',
			'match'    => 'all',
			'not'      => false,
			'children' => array(),
		);
	}

	/**
	 * Get the stored rule tree for a term.
	 *
	 * @param int $term_id Term ID.
	 * @return array
	 */
	public static function get_rules( $term_id ) {
		$raw = get_term_meta( (int) $term_id, self::META_RULES, true );
		if ( empty( $raw ) ) {
			return self::default_tree();
		}
		$tree = is_array( $raw ) ? $raw : json_decode( $raw, true );
		if ( ! is_array( $tree ) || empty( $tree['type'] ) ) {
			return self::default_tree();
		}
		return $tree;
	}

	/**
	 * Persist a rule tree (stored as JSON for portability).
	 *
	 * @param int   $term_id Term ID.
	 * @param array $tree    Rule tree (already sanitised).
	 * @return void
	 */
	public static function save_rules( $term_id, $tree ) {
		// Store the array directly. wp_slash() compensates for the wp_unslash()
		// the metadata API applies on save, preserving multibyte (e.g. Turkish)
		// characters that JSON encoding + unslashing would otherwise corrupt.
		update_term_meta( (int) $term_id, self::META_RULES, wp_slash( $tree ) );
		self::clear_smart_term_cache();
	}

	/**
	 * Whether smart matching is enabled for a term.
	 *
	 * @param int $term_id Term ID.
	 * @return bool
	 */
	public static function is_enabled( $term_id ) {
		return '1' === get_term_meta( (int) $term_id, self::META_ENABLED, true );
	}

	/**
	 * Enable / disable smart matching for a term.
	 *
	 * @param int  $term_id Term ID.
	 * @param bool $enabled Enabled flag.
	 * @return void
	 */
	public static function set_enabled( $term_id, $enabled ) {
		update_term_meta( (int) $term_id, self::META_ENABLED, $enabled ? '1' : '0' );
		self::clear_smart_term_cache();
	}

	/**
	 * IDs of all categories that have smart matching enabled.
	 *
	 * @return int[]
	 */
	public static function get_smart_term_ids() {
		$cached = wp_cache_get( 'smart_term_ids', 'scwc' );
		if ( false !== $cached ) {
			return $cached;
		}

		$ids = get_terms(
			array(
				'taxonomy'   => self::taxonomies(),
				'hide_empty' => false,
				'fields'     => 'ids',
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded lookup on a single indexed flag; the result is cached for an hour right below.
				'meta_query' => array(
					array(
						'key'   => self::META_ENABLED,
						'value' => '1',
					),
				),
			)
		);

		$ids = ( is_array( $ids ) ) ? array_map( 'intval', $ids ) : array();
		wp_cache_set( 'smart_term_ids', $ids, 'scwc', HOUR_IN_SECONDS );
		return $ids;
	}

	/**
	 * Invalidate the smart-term-id cache.
	 */
	public static function clear_smart_term_cache() {
		wp_cache_delete( 'smart_term_ids', 'scwc' );
	}

	/**
	 * Recursively sanitise a rule tree received from the client.
	 *
	 * @param array $node  Node.
	 * @param int   $depth Current depth.
	 * @return array Clean node.
	 */
	public static function sanitize_tree( $node, $depth = 0 ) {
		if ( ! is_array( $node ) ) {
			return self::default_tree();
		}

		$type = isset( $node['type'] ) ? $node['type'] : ( isset( $node['children'] ) ? 'group' : 'condition' );

		if ( 'group' === $type ) {
			$children = array();
			if ( $depth < self::MAX_DEPTH && isset( $node['children'] ) && is_array( $node['children'] ) ) {
				foreach ( $node['children'] as $child ) {
					$clean = self::sanitize_tree( $child, $depth + 1 );
					if ( null !== $clean ) {
						$children[] = $clean;
					}
				}
			}
			// A nested group with no surviving children carries no constraint. Drop
			// it so it can never poison an "any" parent into matching everything
			// (fail-open). The root stays a group even when empty.
			if ( 0 !== $depth && empty( $children ) ) {
				return null;
			}

			return array(
				'type'     => 'group',
				'match'    => ( isset( $node['match'] ) && 'any' === $node['match'] ) ? 'any' : 'all',
				'not'      => ! empty( $node['not'] ),
				'children' => $children,
			);
		}

		// Condition.
		$field    = isset( $node['field'] ) ? sanitize_text_field( (string) $node['field'] ) : '';
		$operator = isset( $node['operator'] ) ? sanitize_key( (string) $node['operator'] ) : '';
		if ( '' === $field || '' === $operator ) {
			// The root must always be a group; an invalid root becomes empty.
			return 0 === $depth ? self::default_tree() : null;
		}

		$condition = array(
			'type'     => 'condition',
			'field'    => $field,
			'operator' => $operator,
			'value'    => self::sanitize_value( isset( $node['value'] ) ? $node['value'] : null ),
		);

		// Guarantee the contract "root is always a group" by wrapping a bare
		// condition supplied at the top level.
		if ( 0 === $depth ) {
			return array(
				'type'     => 'group',
				'match'    => 'all',
				'not'      => false,
				'children' => array( $condition ),
			);
		}

		return $condition;
	}

	/**
	 * Sanitise a condition value (scalar or array of scalars).
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	private static function sanitize_value( $value ) {
		if ( is_array( $value ) ) {
			$clean = array();
			foreach ( $value as $item ) {
				$clean[] = is_scalar( $item ) ? sanitize_text_field( (string) $item ) : '';
			}
			return $clean;
		}
		if ( is_bool( $value ) ) {
			return $value;
		}
		if ( is_scalar( $value ) ) {
			return sanitize_text_field( (string) $value );
		}
		return '';
	}
}
