<?php
/**
 * Frontend display for Estimated Delivery for WooCommerce.
 * Uses actions and filters throughout so add-ons/custom code can customize output and add locations.
 *
 * @package EstimatedDeliveryDateWC
 */

declare( strict_types=1 );

defined( 'ABSPATH' ) || exit;

/**
 * Class EDFW_Frontend
 *
 * @since 1.0.0
 */
final class EDFW_Frontend {

	/**
	 * Singleton instance.
	 *
	 * @since 1.0.0
	 * @var EDFW_Frontend|null
	 */
	private static ?EDFW_Frontend $instance = null;

	/**
	 * Get the singleton instance.
	 *
	 * @since 1.0.0
	 * @return EDFW_Frontend
	 */
	public static function instance(): EDFW_Frontend {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Register storefront display, order and email hooks.
	 *
	 * @since 1.0.0
	 */
	private function __construct() {
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
		add_shortcode( 'edfw_estimated_delivery', array( $this, 'shortcode' ) );

		$this->register_display_hooks();
		add_action( 'woocommerce_checkout_create_order_line_item', array( $this, 'add_order_item_meta' ), 10, 3 );
		add_action( 'woocommerce_order_item_meta_start', array( $this, 'order_item_meta' ), 10, 2 );
		add_filter( 'woocommerce_email_styles', array( $this, 'email_styles' ), 10, 2 );

		add_action( 'edfw_loaded', array( $this, 'register_addon_display_hooks' ) );
	}

	/**
	 * Register display hooks based on settings. Add-ons can add thank_you, mini_cart, etc. via edfw_display_locations.
	 *
	 * @since 1.0.0
	 */
	protected function register_display_hooks(): void {
		$show_single  = EDFW_Backend::get_setting( 'show_single', 'yes' ) === 'yes';
		$show_archive = EDFW_Backend::get_setting( 'show_archive', 'yes' ) === 'yes';
		$pos_archive  = EDFW_Backend::get_setting( 'position_archive', 'above_add_to_cart' );
		$pos_single   = EDFW_Backend::get_setting( 'position_single', '31' );

		if ( $show_archive ) {
			$archive_hooks = array(
				'under_title'       => array( 'woocommerce_shop_loop_item_title', 11 ),
				'under_rating'      => array( 'woocommerce_after_shop_loop_item_title', 6 ),
				'under_price'       => array( 'woocommerce_after_shop_loop_item_title', 11 ),
				'above_add_to_cart' => array( 'woocommerce_after_shop_loop_item', 9 ),
				'under_add_to_cart' => array( 'woocommerce_after_shop_loop_item', 11 ),
			);
			/**
			 * Filter the WooCommerce hooks used for each shop/archive position.
			 *
			 * @since 1.0.0
			 * @param array $archive_hooks Position slug => [ hook name, priority ].
			 */
			$archive_hooks = (array) apply_filters( 'edfw_archive_display_hooks', $archive_hooks );
			if ( isset( $archive_hooks[ $pos_archive ] ) && 'none' !== $pos_archive ) {
				list( $hook, $prio ) = $archive_hooks[ $pos_archive ];
				add_action( $hook, array( $this, 'display_product' ), (int) $prio );
			}
		}

		if ( $show_single ) {
			/**
			 * Filter the named single-product positions and their hook priorities.
			 *
			 * @since 1.0.0
			 * @param array $pos_map Position slug => woocommerce_single_product_summary priority.
			 */
			$pos_map  = (array) apply_filters(
				'edfw_single_position_map',
				array(
					'after_price'      => 11,
					'after_short_desc' => 21,
					'before_cart'      => 29,
					'after_cart'       => 31,
					'after_meta'       => 41,
				)
			);
			$position = (string) EDFW_Backend::get_setting( 'position', '' );
			if ( isset( $pos_map[ $position ] ) ) {
				$prio = (int) $pos_map[ $position ];
			} else {
				$prio = is_numeric( $pos_single ) ? (int) $pos_single : 31;
			}
			if ( $prio > 0 ) {
				add_action( 'woocommerce_single_product_summary', array( $this, 'display_product' ), $prio );
			}
		}

		$cart_item = EDFW_Backend::get_setting( 'cart_item', 'no' );
		if ( 'yes' === $cart_item ) {
			add_filter( 'woocommerce_cart_item_name', array( $this, 'cart_item_name' ), 10, 2 );
		} elseif ( 'yes_data' === $cart_item ) {
			add_filter( 'woocommerce_get_item_data', array( $this, 'cart_item_data' ), 10, 2 );
		}

		if ( EDFW_Backend::get_setting( 'cart_overall', 'yes' ) !== 'no' ) {
			add_action( 'woocommerce_cart_contents', array( $this, 'cart_overall' ) );
		}

		if ( EDFW_Backend::get_setting( 'show_checkout', 'yes' ) === 'yes' ) {
			add_action( 'woocommerce_review_order_after_cart_contents', array( $this, 'checkout_overall' ) );
		}

		// Order-email display is handled by add_order_item_meta()/order_item_meta(),
		// which honor the 'order_item' setting directly.

		/**
		 * Add-ons can hook here to add thank_you, mini_cart, account display.
		 *
		 * @param EDFW_Frontend $frontend Frontend instance.
		 * @since 1.0.0
		 */
		do_action( 'edfw_register_display_hooks', $this );
	}

	/**
	 * Add-ons add display for thank you, mini-cart, account, etc.
	 *
	 * @since 1.0.0
	 */
	public function register_addon_display_hooks(): void {
		// No-op by default. Add-ons hook edfw_loaded and add actions.
	}

	/**
	 * Enqueue the storefront stylesheet and the countdown script.
	 *
	 * @since 1.0.0
	 */
	public function enqueue_scripts(): void {
		if ( ! is_woocommerce() && ! is_cart() && ! is_checkout() ) {
			return;
		}
		// Version by mtime so shoppers never get a stale stylesheet after an update.
		$css_ver = EDFW_VERSION . '.' . (string) filemtime( EDFW_DIR . 'assets/css/frontend.css' );
		wp_enqueue_style( 'edfw-frontend', EDFW_URI . 'assets/css/frontend.css', array(), $css_ver );

		if ( EDFW_Backend::get_setting( 'countdown_enabled', 'yes' ) !== 'no' ) {
			$js_ver = EDFW_VERSION . '.' . (string) filemtime( EDFW_DIR . 'assets/js/frontend.js' );
			wp_enqueue_script( 'edfw-frontend', EDFW_URI . 'assets/js/frontend.js', array(), $js_ver, true );
			wp_localize_script(
				'edfw-frontend',
				'edfwFrontend',
				array(
					/* translators: %s: time remaining like "2h 36m" */
					'orderWithin'  => __( 'Order within %s', 'wpactpro-estimated-delivery-date' ),
					'cutoffPassed' => self::cutoff_passed_text(),
				)
			);
		}

		/**
		 * Enqueue extra frontend assets (e.g. add-ons).
		 *
		 * @since 1.0.0
		 */
		do_action( 'edfw_enqueue_frontend_assets' );
	}

	/**
	 * Display estimated delivery for current product (single/archive).
	 *
	 * @since 1.0.0
	 */
	public function display_product(): void {
		global $product;
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return;
		}

		/**
		 * Skip display for this product? add-ons/custom code can filter.
		 *
		 * @param bool       $ignore  True to skip.
		 * @param WC_Product $product Product.
		 * @since 1.0.0
		 */
		if ( apply_filters( 'edfw_ignore_product', false, $product ) ) {
			return;
		}

		$html = $this->get_delivery_html( $product, 'product' );
		if ( '' === $html ) {
			return;
		}

		/**
		 * Before delivery output. Add-ons can wrap with extra markup.
		 *
		 * @param string     $html    Output HTML.
		 * @param WC_Product $product Product.
		 * @param string     $context product|cart|checkout|email.
		 * @since 1.0.0
		 */
		echo wp_kses_post( apply_filters( 'edfw_before_delivery_output', '', $product, 'product' ) );
		echo wp_kses_post( $html );
		/**
		 * After delivery output.
		 *
		 * @param string     $html    Output HTML.
		 * @param WC_Product $product Product.
		 * @param string     $context product|cart|checkout|email.
		 * @since 1.0.0
		 */
		echo wp_kses_post( apply_filters( 'edfw_after_delivery_output', '', $product, 'product' ) );
	}

	/**
	 * Get delivery HTML for a product. Filterable.
	 *
	 * @since 1.0.0
	 * @param WC_Product $product Product.
	 * @param string     $context product|cart|checkout|email|shortcode.
	 * @param string     $type    full|plain|u (unix).
	 * @return string
	 */
	public function get_delivery_html( $product, string $context = 'product', string $type = 'full' ): string {
		if ( EDFW_Backend::get_setting( 'enabled', 'yes' ) === 'no' ) {
			return '';
		}
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return '';
		}
		/**
		 * Filter whether a product is excluded from delivery estimates entirely.
		 *
		 * @since 1.0.0
		 * @param bool $ignore True to show no estimate.
		 * @param WC_Product $product Product being evaluated.
		 */
		if ( apply_filters( 'edfw_ignore_product', false, $product ) ) {
			return '';
		}

		$rule = $this->get_matched_rule( $product );
		if ( empty( $rule ) || ( ( $rule['min'] ?? '' ) === '' && ( $rule['max'] ?? '' ) === '' ) ) {
			return '';
		}

		$min_days  = isset( $rule['min'] ) && '' !== $rule['min'] ? (int) $rule['min'] : null;
		$max_days  = isset( $rule['max'] ) && '' !== $rule['max'] ? (int) $rule['max'] : null;
		$scheduled = $rule['scheduled'] ?? '';

		$min_date = null !== $min_days ? $this->calculate_date( $min_days, $scheduled ) : null;
		$max_date = null !== $max_days ? $this->calculate_date( $max_days, $scheduled ) : null;

		/**
		 * Override calculated dates. Add-ons can apply a carrier API, etc.
		 *
		 * @param array|null $min_max [ min_unix, max_unix ] or null.
		 * @param WC_Product $product Product.
		 * @param array      $rule    Matched rule.
		 * @since 1.0.0
		 */
		$overrides = apply_filters( 'edfw_calculated_delivery_dates', null, $product, $rule );
		if ( is_array( $overrides ) && isset( $overrides[0], $overrides[1] ) ) {
			$min_date = $overrides[0];
			$max_date = $overrides[1];
		}

		$formatted   = '';
		$is_min_only = null === $max_date && null !== $min_date;
		$is_max_only = null === $min_date && null !== $max_date;

		if ( 'plain' === $type || 'text' === $type ) {
			if ( null !== $min_date ) {
				$formatted .= $this->format_date( $min_date );
			}
			if ( null !== $max_date ) {
				if ( null !== $min_date ) {
					/**
					 * Filter the separator shown between the earliest and latest dates.
					 *
					 * @since 1.0.0
					 * @param string $separator Separator, including surrounding spaces.
					 */
					$formatted .= apply_filters( 'edfw_dates_separator', ' – ' );
				}
				$formatted .= $this->format_date( $max_date );
			}
			/**
			 * Filter the plain-text estimate used in cart rows, emails and the REST payload.
			 *
			 * @since 1.0.0
			 * @param string $formatted Formatted date or range, without markup.
			 * @param WC_Product $product Product.
			 * @param string $context product|cart|checkout|email|rest|shortcode.
			 */
			return (string) apply_filters( 'edfw_delivery_plain_text', $formatted, $product, $context );
		}

		if ( 'u' === $type || 'U' === $type ) {
			return null !== $max_date ? (string) $max_date : ( null !== $min_date ? (string) $min_date : '' );
		}

		$start = null !== $min_date ? $this->format_date( $min_date ) : '';
		$end   = null !== $max_date ? $this->format_date( $max_date ) : '';

		$template = (string) EDFW_Backend::get_setting( 'message_template', '' );
		if ( '' !== $template && false !== strpos( $template, '{' ) ) {
			$days_txt = ( null !== $min_days && null !== $max_days )
				/* translators: 1: minimum business days, 2: maximum business days. */
				? sprintf( _n( '%1$s–%2$s day', '%1$s–%2$s days', (int) $max_days, 'wpactpro-estimated-delivery-date' ), $min_days, $max_days )
				: '';
			$rendered = strtr(
				$template,
				array(
					'{start}' => $start,
					'{end}'   => $end,
					'{days}'  => $days_txt,
				)
			);
		} else {
			if ( $is_max_only ) {
				/* translators: %s: latest delivery date. */
				$msg = EDFW_Backend::get_setting( 'text_max', __( 'Latest estimated delivery: %s', 'wpactpro-estimated-delivery-date' ) );
			} elseif ( $is_min_only ) {
				/* translators: %s: earliest delivery date. */
				$msg = EDFW_Backend::get_setting( 'text_min', __( 'Earliest estimated delivery: %s', 'wpactpro-estimated-delivery-date' ) );
			} else {
				/* translators: %s: delivery date or date range. */
				$msg = EDFW_Backend::get_setting( 'text', __( 'Estimated delivery: %s', 'wpactpro-estimated-delivery-date' ) );
			}
			if ( '' === $msg ) {
				/* translators: %s: delivery date or date range. */
				$msg = __( 'Estimated delivery: %s', 'wpactpro-estimated-delivery-date' );
			}
			$formatted = $start;
			if ( '' !== $end ) {
				/**
				 * Filter the separator shown between the earliest and latest dates.
				 *
				 * @since 1.0.0
				 * @param string $separator Separator, including surrounding spaces.
				 */
				$formatted .= ( '' !== $start ? apply_filters( 'edfw_dates_separator', ' – ' ) : '' ) . $end;
			}
			$rendered = sprintf( $msg, $formatted );
		}

		$accent    = (string) EDFW_Backend::get_setting( 'accent_color', '' );
		$style     = '' !== $accent ? ' style="--edfw-accent:' . esc_attr( $accent ) . '"' : '';
		$icon_key  = sanitize_html_class( (string) EDFW_Backend::get_setting( 'icon', 'truck' ) );
		$icon_html = '<span class="edfw-delivery__icon edfw-icon-' . esc_attr( $icon_key ) . '" aria-hidden="true"></span>';

		// Countdown lives inside the badge, stacked under the message — same
		// structure as the admin live preview, so it can't wrap into
		// surrounding theme text.
		$countdown_html = '';
		if ( EDFW_Backend::get_setting( 'countdown_enabled', 'yes' ) !== 'no' && 'product' === $context ) {
			$cutoff = (string) EDFW_Backend::get_setting( 'cutoff_time', '' );
			if ( '' !== $cutoff && false !== strpos( $cutoff, ':' ) ) {
				$remaining = self::cutoff_remaining_seconds( $cutoff );
				if ( $remaining > 0 ) {
					$countdown_html = '<span class="edfw-countdown" data-remaining="' . esc_attr( (string) $remaining ) . '"></span>';
				} else {
					$countdown_html = '<span class="edfw-countdown edfw-countdown--passed">' . esc_html( self::cutoff_passed_text() ) . '</span>';
				}
			}
		}

		$html = '<span class="edfw-delivery edfw-' . esc_attr( $context ) . '"' . $style . '>' . $icon_html
			. '<span class="edfw-delivery__body">'
			. '<span class="edfw-delivery__text">' . esc_html( $rendered ) . '</span>'
			. $countdown_html
			. '</span></span>';

		/**
		 * Filter final delivery HTML. add-ons/custom code can change markup.
		 *
		 * @param string     $html    HTML output.
		 * @param WC_Product $product Product.
		 * @param string     $context product|cart|checkout|email|shortcode.
		 * @param array      $rule    Matched rule.
		 * @since 1.0.0
		 */
		return (string) apply_filters( 'edfw_delivery_html', $html, $product, $context, $rule );
	}

	/**
	 * Structured delivery estimate for a product, for use by the REST API,
	 * Store API and any integration. Returns an empty array when there is no
	 * estimate (disabled, no matching rule, or invalid product).
	 *
	 * @since 1.0.0
	 * @param WC_Product      $product Product.
	 * @param int|string|null $method  Optional shipping method instance ID.
	 * @return array<string, mixed>
	 */
	public function get_estimate_data( $product, $method = null ): array {
		if ( EDFW_Backend::get_setting( 'enabled', 'yes' ) === 'no' ) {
			return array();
		}
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return array();
		}
		$rule = $this->get_matched_rule( $product, $method );
		if ( empty( $rule ) ) {
			return array();
		}
		$min_days = isset( $rule['min'] ) && '' !== $rule['min'] ? (int) $rule['min'] : null;
		$max_days = isset( $rule['max'] ) && '' !== $rule['max'] ? (int) $rule['max'] : null;
		if ( null === $min_days && null === $max_days ) {
			return array();
		}
		$scheduled = $rule['scheduled'] ?? '';
		$min_date  = null !== $min_days ? $this->calculate_date( $min_days, $scheduled ) : null;
		$max_date  = null !== $max_days ? $this->calculate_date( $max_days, $scheduled ) : null;

		/**
		 * Filter the calculated earliest and latest timestamps.
		 * Return [ min_unix, max_unix ] to override both, or null to leave them alone.
		 *
		 * @since 1.0.0
		 * @param array|null $min_max Overridden timestamps, or null.
		 * @param WC_Product $product Product.
		 * @param array $rule Matched rule.
		 */
		$overrides = apply_filters( 'edfw_calculated_delivery_dates', null, $product, $rule );
		if ( is_array( $overrides ) && isset( $overrides[0], $overrides[1] ) ) {
			$min_date = $overrides[0];
			$max_date = $overrides[1];
		}

		// calculate_date() returns a current_time('timestamp')-style local-shifted
		// timestamp, so format the ISO date with gmdate() (which matches that
		// convention) — NOT wp_date(), which would re-apply the offset.
		$data = array(
			'enabled'        => true,
			'min_days'       => $min_days,
			'max_days'       => $max_days,
			'from'           => $min_date ? gmdate( 'Y-m-d', $min_date ) : null,
			'to'             => $max_date ? gmdate( 'Y-m-d', $max_date ) : null,
			'from_formatted' => null !== $min_date ? $this->format_date( $min_date ) : null,
			'to_formatted'   => null !== $max_date ? $this->format_date( $max_date ) : null,
			'text'           => wp_strip_all_tags( $this->get_delivery_html( $product, 'rest', 'plain' ) ),
		);
		/**
		 * Filter the structured estimate returned to the REST and Store APIs.
		 *
		 * @since 1.0.0
		 * @param array $data Estimate payload.
		 * @param WC_Product $product Product.
		 * @param array $rule Matched rule.
		 * @param int|string|null $method Shipping method instance id, when known.
		 */
		return (array) apply_filters( 'edfw_estimate_data', $data, $product, $rule, $method );
	}

	/**
	 * Get matched rule for product. Filterable (Add-ons can override).
	 *
	 * @since 1.0.0
	 * @param WC_Product      $product Product.
	 * @param int|string|null $method  Shipping method instance ID (optional).
	 * @return array<string, mixed>
	 */
	public function get_matched_rule( $product, $method = null ): array {
		if ( ! $product || ! is_a( $product, 'WC_Product' ) || ! $product->is_purchasable() || $product->is_type( 'external' ) || $product->is_virtual() ) {
			return array();
		}

		$enable = (string) get_post_meta( $product->get_id(), '_edfw_enable', true );
		$enable = '' !== $enable ? $enable : 'global';
		if ( 'disable' === $enable ) {
			return array();
		}

		$rules = 'override' === $enable
			? (array) get_post_meta( $product->get_id(), '_edfw_rules', true )
			: EDFW_Backend::get_rules();

		$zone        = EDFW_Helper::get_shipping_zone();
		$user_method = $method ?? EDFW_Helper::get_selected_method();

		// Among all rules whose conditions match, the MOST SPECIFIC one wins
		// (a product rule beats a category rule beats an "Any" default), so
		// admins don't have to worry about ordering. Ties fall back to list
		// order (the earlier rule wins).
		$matched    = array();
		$best_score = -1;
		foreach ( $rules as $key => $rule ) {
			$rule       = (array) $rule;
			$conditions = isset( $rule['conditions'] ) && is_array( $rule['conditions'] ) ? $rule['conditions'] : array();
			if ( ! $this->rule_conditions_match( $conditions, $product, $zone, $user_method ) ) {
				continue;
			}
			$score = $this->rule_specificity( $conditions );
			if ( $score > $best_score ) {
				$best_score     = $score;
				$matched        = array_merge( EDFW_Helper::base_rule(), $rule );
				$matched['key'] = $key;
			}
		}

		// Nothing matched: the estimate is hidden for this product (opt-in).
		if ( empty( $matched ) ) {
			return array();
		}

		/**
		 * Add-ons can override or refine the matched rule (e.g. order-value conditions).
		 *
		 * @param array      $matched Matched rule.
		 * @param WC_Product $product Product.
		 * @param int|string|null $method Shipping method.
		 * @since 1.0.0
		 */
		return (array) apply_filters( 'edfw_matched_rule', $matched, $product, $method );
	}

	/**
	 * A rule matches when ALL of its conditions pass (AND). A rule with no
	 * conditions matches everything.
	 *
	 * @since 1.0.0
	 * @param array                 $conditions  List of { type, op, vals }.
	 * @param WC_Product            $product     Product.
	 * @param WC_Shipping_Zone|null $zone        Current shipping zone.
	 * @param int|string|null       $user_method Chosen shipping method instance id.
	 * @return bool
	 */
	protected function rule_conditions_match( array $conditions, $product, $zone, $user_method ): bool {
		foreach ( $conditions as $condition ) {
			if ( ! $this->condition_passes( (array) $condition, $product, $zone, $user_method ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Evaluate a single condition. Empty values ("Any") always pass. Zone/method
	 * conditions pass when the value is unknown (e.g. on the product page before
	 * a shipping method is chosen), so they only filter once it's known.
	 *
	 * @since 1.0.0
	 * @param array                 $c           Condition { type, op, vals }.
	 * @param WC_Product            $product     Product.
	 * @param WC_Shipping_Zone|null $zone        Current zone.
	 * @param int|string|null       $user_method Chosen method instance id.
	 * @return bool
	 */
	protected function condition_passes( array $c, $product, $zone, $user_method ): bool {
		$type = sanitize_key( $c['type'] ?? 'product' );
		$op   = ( ( $c['op'] ?? 'is' ) === 'is_not' ) ? 'is_not' : 'is';
		$vals = array_map( 'strval', (array) ( $c['vals'] ?? array() ) );
		if ( empty( $vals ) ) {
			return true; // "Any".
		}

		switch ( $type ) {
			case 'product':
				$in = in_array( (string) $product->get_id(), $vals, true );
				break;
			case 'product_cat':
				$in = (bool) has_term( $vals, 'product_cat', $product->get_id() );
				break;
			case 'shipping_class':
				$in = in_array( (string) $product->get_shipping_class(), $vals, true );
				break;
			case 'zone':
				if ( ! $zone ) {
					return true; // Unknown — don't block.
				}
				$in = in_array( (string) $zone->get_id(), $vals, true );
				break;
			case 'method':
				if ( null === $user_method ) {
					return true; // Not chosen yet (e.g. product page) — don't block.
				}
				$in = in_array( (string) $user_method, $vals, true );
				break;
			default:
				/**
				 * Whether the product matches an add-on condition type.
				 *
				 * Defaults to false, so a rule using a condition whose add-on is no
				 * longer active stops matching rather than matching everything.
				 *
				 * The whole condition is passed as the fourth argument because the
				 * return value is about to be inverted for an "is not" rule. An add-on
				 * that cannot evaluate the condition — the shopper's country is not
				 * known yet, say — needs the operator to express that outcome
				 * deliberately, rather than returning a bool and having its meaning
				 * silently reversed. Return `'is_not' === $c['op']` to never match
				 * either way, or `'is' === $c['op']` to always pass.
				 *
				 * @since 1.0.0
				 * @param bool       $in      Whether the condition matches.
				 * @param array      $vals    Selected values.
				 * @param WC_Product $product Product being evaluated.
				 * @param array      $c       The full condition: type, op, vals.
				 */
				$in = (bool) apply_filters( 'edfw_condition_in_' . $type, false, $vals, $product, $c );
		}

		return 'is_not' === $op ? ! $in : $in;
	}

	/**
	 * Score how specific a rule is, so the most specific matching rule wins:
	 * a product rule beats a category/class rule beats a zone/method filter
	 * beats an "Any" default. Exclusions ("is not") are broad, so they score low.
	 *
	 * @since 1.0.0
	 * @param array $conditions Rule conditions.
	 * @return int
	 */
	protected function rule_specificity( array $conditions ): int {
		$weights = array(
			'product'        => 100,
			'product_cat'    => 50,
			'shipping_class' => 40,
			'zone'           => 15,
			'method'         => 15,
		);
		$score   = 0;
		foreach ( $conditions as $c ) {
			$c    = (array) $c;
			$vals = (array) ( $c['vals'] ?? array() );
			if ( empty( $vals ) ) {
				continue; // "Any" adds no specificity.
			}
			$type = sanitize_key( $c['type'] ?? '' );
			/**
			 * Filter the specificity weight of an add-on condition type.
			 * The hook name ends with the condition type.
			 *
			 * @since 1.0.0
			 * @param int $weight Default weight for unknown condition types.
			 */
			$w = $weights[ $type ] ?? (int) apply_filters( 'edfw_condition_weight_' . $type, 30 );
			if ( ( $c['op'] ?? 'is' ) === 'is_not' ) {
				$w = 5; // Exclusions match broadly — low priority.
			}
			$score += $w;
		}
		/**
		 * Filter a rule's specificity score, which decides that the most specific
		 * matching rule wins.
		 *
		 * @since 1.0.0
		 * @param int $score Computed score.
		 * @param array $conditions Rule conditions.
		 */
		return (int) apply_filters( 'edfw_rule_specificity', $score, $conditions );
	}

	/**
	 * Calculate delivery date from days. Skips weekends if in skipped_dates. Add-ons can filter.
	 *
	 * @since 1.0.0
	 * @param int    $days     Days to add.
	 * @param string $scheduled Optional scheduled start date (Y-m-d), for add-on use.
	 * @return int|null Unix timestamp or null.
	 */
	public function calculate_date( int $days, string $scheduled = '' ): ?int {
		// phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- a store-local timestamp is intentional: the business-day walk pairs it with gmdate() to read store-local weekdays.
		$now = current_time( 'timestamp' );
		// A scheduled start only applies while it is still in the future; once it
		// passes (or was never set) we count from today like any other product.
		$scheduled_ts = '' !== $scheduled ? strtotime( $scheduled ) : false;
		if ( false !== $scheduled_ts && $scheduled_ts > $now ) {
			$now = $scheduled_ts;
		} else {
			// Counting from today: orders placed after the cutoff start
			// processing the next business day.
			$days += self::cutoff_offset();
		}
		$skipped = self::get_skipped_days();
		/**
		 * Filter the safety cap on the business-day walk.
		 * A schedule that skips every day would otherwise loop forever.
		 *
		 * @since 1.0.0
		 * @param int $max Maximum days to step through.
		 */
		$max = (int) apply_filters( 'edfw_calculate_date_max_iterations', 365 );

		// Cache calculated dates for an hour (per day + settings version) when enabled.
		$caching = EDFW_Backend::get_setting( 'caching', 'yes' ) !== 'no';
		$key     = '';
		if ( $caching ) {
			$key    = 'edfw_d_' . md5( $days . '|' . $scheduled . '|' . gmdate( 'Y-m-d', (int) $now ) . '|' . wp_json_encode( $skipped ) . '|' . EDFW_Backend::cache_version() );
			$cached = get_transient( $key );
			if ( false !== $cached ) {
				return 'null' === $cached ? null : (int) $cached;
			}
		}

		$result = self::calculate_business_date( $days, (int) $now, $skipped, $max );

		/**
		 * Add-ons can override the calculated date (e.g. a carrier API).
		 *
		 * @param int|null $result   Unix timestamp or null.
		 * @param int     $days     Days.
		 * @param string  $scheduled Scheduled date.
		 * @since 1.0.0
		 */
		$result = apply_filters( 'edfw_calculated_delivery_date', $result, $days, $scheduled );
		$result = is_numeric( $result ) ? (int) $result : null;

		if ( $caching && '' !== $key ) {
			set_transient( $key, null === $result ? 'null' : $result, HOUR_IN_SECONDS );
		}
		return $result;
	}

	/**
	 * Build the skipped-days set for the calculator: non-working weekdays plus
	 * active custom holidays. Shared by the storefront and REST preview.
	 *
	 * @since 1.0.0
	 * @return array<int, array>
	 */
	public static function get_skipped_days(): array {
		$skipped = array();

		$working = (array) EDFW_Backend::get_setting( 'working_days', array( 1, 2, 3, 4, 5 ) );
		$working = array_map( 'intval', $working );
		for ( $w = 0; $w <= 6; $w++ ) {
			if ( ! in_array( $w, $working, true ) ) {
				$skipped[] = array( 'type' => $w );
			}
		}

		foreach ( (array) EDFW_Backend::get_setting( 'skipped_dates', array() ) as $h ) {
			$h  = (array) $h;
			$on = $h['on'] ?? 'yes';
			if ( ( $h['type'] ?? 'cus' ) === 'cus' && ! empty( $h['val'] ) && 'no' !== $on ) {
				$skipped[] = array(
					'type' => 'cus',
					'val'  => $h['val'],
				);
			}
		}

		/**
		 * Filter the set of non-working days used in the calculation.
		 *
		 * @since 1.0.0
		 * @param array $skipped Entries of [ type => weekday int|'cus', val => Y-m-d ].
		 */
		return (array) apply_filters( 'edfw_skipped_days', $skipped );
	}

	/**
	 * Pure business-day calculator. No WP calls — safe to unit test and reuse in preview.
	 *
	 * @since 1.0.0
	 * @param int   $days     Business days to add.
	 * @param int   $from_ts  Start timestamp.
	 * @param array $skipped  Skipped-date rules ([ ['type'=>int|'cus','val'=>'Y-m-d'], ... ]).
	 * @param int   $max_iter Safety cap.
	 * @return int|null Timestamp or null if unreachable.
	 */
	public static function calculate_business_date( int $days, int $from_ts, array $skipped, int $max_iter = 365 ): ?int {
		$count = 0;
		$cur   = $from_ts;
		$limit = $max_iter;
		while ( $count < $days && $limit > 0 ) {
			$cur += DAY_IN_SECONDS;
			--$limit;
			$w    = (int) gmdate( 'w', $cur );
			$d    = gmdate( 'Y-m-d', $cur );
			$skip = false;
			foreach ( $skipped as $s ) {
				$s = (array) $s;
				$t = $s['type'] ?? '';
				$v = $s['val'] ?? '';
				if ( 'cus' === $t && '' !== $v && $d === $v ) {
					$skip = true;
					break; }
				if ( is_numeric( $t ) && (int) $t === $w ) {
					$skip = true;
					break; }
			}
			if ( ! $skip ) {
				++$count; }
		}
		return $limit > 0 ? $cur : null;
	}

	/**
	 * Seconds remaining until today's cutoff in the store timezone (0 if none/passed).
	 *
	 * @since 1.0.0
	 * @param string $cutoff HH:MM (24h) or empty.
	 * @return int
	 */
	public static function cutoff_remaining_seconds( string $cutoff ): int {
		if ( '' === $cutoff || false === strpos( $cutoff, ':' ) ) {
			return 0;
		}
		list( $h, $m ) = array_map( 'intval', explode( ':', $cutoff ) );
		$tz            = wp_timezone();
		$now           = new DateTime( 'now', $tz );
		$target        = new DateTime( $now->format( 'Y-m-d' ) . sprintf( ' %02d:%02d:00', $h, $m ), $tz );
		$diff          = $target->getTimestamp() - $now->getTimestamp();
		return $diff > 0 ? $diff : 0;
	}

	/**
	 * Note shown in place of the countdown once today's cutoff has passed.
	 * Mirrors the admin live preview so storefront and preview agree.
	 *
	 * @since 1.0.0
	 * @return string
	 */
	public static function cutoff_passed_text(): string {
		/**
		 * Filter the "cutoff passed" note.
		 *
		 * @param string $text Note text.
		 * @since 1.0.0
		 */
		return (string) apply_filters(
			'edfw_cutoff_passed_text',
			__( 'Cutoff passed — ships next business day', 'wpactpro-estimated-delivery-date' )
		);
	}

	/**
	 * Extra business day to add when the current time is past the cutoff.
	 * Mirrors the admin live preview so storefront and preview agree.
	 *
	 * @since 1.0.0
	 * @return int 0 or 1.
	 */
	public static function cutoff_offset(): int {
		$cutoff = (string) EDFW_Backend::get_setting( 'cutoff_time', '' );
		if ( '' === $cutoff || false === strpos( $cutoff, ':' ) ) {
			return 0;
		}
		return self::cutoff_remaining_seconds( $cutoff ) > 0 ? 0 : 1;
	}

	/**
	 * Format date for display. Filterable.
	 *
	 * @since 1.0.0
	 * @param int $timestamp Unix timestamp.
	 * @return string
	 */
	public function format_date( int $timestamp ): string {
		$format = EDFW_Backend::get_setting( 'date_format_custom', 'M j, Y' );
		/**
		 * Filter the date format used to render an estimate.
		 *
		 * @since 1.0.0
		 * @param string $format PHP date format string.
		 */
		$format = apply_filters( 'edfw_date_format', $format );
		$out    = date_i18n( $format, $timestamp );

		/**
		 * Filter the formatted date string.
		 *
		 * @since 1.0.0
		 * @param string $out       Formatted string.
		 * @param int    $timestamp Unix timestamp.
		 */
		return (string) apply_filters( 'edfw_format_date', $out, $timestamp );
	}

	/**
	 * Shortcode [edfw_estimated_delivery].
	 *
	 * @since 1.0.0
	 * @param array $atts Optional. product_id, context, type.
	 * @return string
	 */
	public function shortcode( array $atts = array() ): string {
		$atts = shortcode_atts(
			array(
				'product_id' => 0,
				'context'    => 'shortcode',
				'type'       => 'full',
			),
			$atts,
			'edfw_estimated_delivery'
		);

		$product_id = (int) $atts['product_id'];
		if ( $product_id <= 0 ) {
			// Fall back to the current product in the loop, without writing the global.
			$product = $GLOBALS['product'] ?? wc_get_product( get_the_ID() );
		} else {
			$product = wc_get_product( $product_id );
		}
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return '';
		}

		/**
		 * Shortcode output. Add-ons can add params.
		 *
		 * @param string     $html    HTML.
		 * @param WC_Product $product Product.
		 * @param array      $atts    Shortcode atts.
		 */
		$html = $this->get_delivery_html( $product, (string) $atts['context'], (string) $atts['type'] );
		/**
		 * Filter the shortcode's rendered output.
		 *
		 * @since 1.0.0
		 * @param string $html Rendered HTML.
		 * @param WC_Product $product Product.
		 * @param array $atts Shortcode attributes.
		 */
		return wp_kses_post( (string) apply_filters( 'edfw_shortcode_output', $html, $product, $atts ) );
	}

	/**
	 * Append the estimate badge to the cart item name.
	 *
	 * @since 1.0.0
	 * @param string $name      Cart item name HTML.
	 * @param array  $cart_item Cart item data.
	 * @return string
	 */
	public function cart_item_name( string $name, array $cart_item ): string {
		$product = $cart_item['data'] ?? null;
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return $name;
		}
		$html = $this->get_delivery_html( $product, 'cart' );
		return $name . ( '' !== $html ? ' ' . $html : '' );
	}

	/**
	 * Add the estimate as a cart item data row.
	 *
	 * @since 1.0.0
	 * @param array $data      Item data rows.
	 * @param array $cart_item Cart item data.
	 * @return array
	 */
	public function cart_item_data( array $data, array $cart_item ): array {
		$product = $cart_item['data'] ?? null;
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return $data;
		}
		$plain = $this->get_delivery_html( $product, 'cart', 'plain' );
		if ( '' === $plain ) {
			return $data;
		}
		$label = EDFW_Backend::get_setting( 'text_cart_item', __( 'Estimated delivery', 'wpactpro-estimated-delivery-date' ) );
		/**
		 * Filter the label shown against a cart item's estimate.
		 *
		 * @since 1.0.0
		 * @param string $label Row label.
		 * @param array $cart_item Cart item.
		 */
		$label  = apply_filters( 'edfw_cart_item_label', $label, $cart_item );
		$data[] = array(
			'key'   => $label,
			'value' => $plain,
		);
		/**
		 * Filter the cart item data rows after the estimate row is added.
		 *
		 * @since 1.0.0
		 * @param array $data Item data rows.
		 * @param array $cart_item Cart item.
		 */
		return (array) apply_filters( 'edfw_cart_item_data', $data, $cart_item );
	}

	/**
	 * Print the overall estimate row in the cart contents table.
	 *
	 * @since 1.0.0
	 */
	public function cart_overall(): void {
		$html = $this->get_cart_overall_html();
		if ( '' === $html ) {
			return;
		}
		echo '<tr><td colspan="6" class="edfw-cart-overall">' . wp_kses_post( $html ) . '</td></tr>';
	}

	/**
	 * Get cart overall delivery text. Filterable.
	 *
	 * @since 1.0.0
	 * @return string
	 */
	public function get_cart_overall_html(): string {
		if ( EDFW_Backend::get_setting( 'enabled', 'yes' ) === 'no' ) {
			return '';
		}
		if ( ! isset( WC()->cart ) ) {
			return '';
		}
		$format = EDFW_Backend::get_setting( 'cart_overall_format', 'latest' );
		/**
		 * Filter how the cart's overall estimate is summarised.
		 *
		 * @since 1.0.0
		 * @param string $format latest|earliest|earliest_latest.
		 */
		$format   = apply_filters( 'edfw_cart_overall_format', $format );
		$min_list = array();
		$max_list = array();

		foreach ( WC()->cart->get_cart() as $item ) {
			$product = $item['data'] ?? null;
			if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
				continue;
			}
			$rule  = $this->get_matched_rule( $product );
			$sched = $rule['scheduled'] ?? '';
			$min_d = isset( $rule['min'] ) && '' !== $rule['min'] ? $this->calculate_date( (int) $rule['min'], $sched ) : null;
			$max_d = isset( $rule['max'] ) && '' !== $rule['max'] ? $this->calculate_date( (int) $rule['max'], $sched ) : null;
			if ( null !== $min_d ) {
				$min_list[] = $min_d;
			}
			if ( null !== $max_d ) {
				$max_list[] = $max_d;
			}
		}

		if ( empty( $min_list ) && empty( $max_list ) ) {
			return '';
		}

		sort( $min_list );
		sort( $max_list );

		if ( 'earliest' === $format ) {
			$date = $this->format_date( (int) reset( $min_list ) );
		} elseif ( 'earliest_latest' === $format ) {
			/**
			 * Filter the separator shown between the earliest and latest dates.
			 *
			 * @since 1.0.0
			 * @param string $separator Separator, including surrounding spaces.
			 */
			$date = $this->format_date( (int) reset( $min_list ) ) . apply_filters( 'edfw_dates_separator', ' – ' ) . $this->format_date( (int) end( $max_list ) );
		} else {
			$date = $this->format_date( (int) end( $max_list ) );
		}

		/* translators: %s: delivery date or date range. */
		$msg = EDFW_Backend::get_setting( 'text_cart_overall', __( 'Overall estimated delivery: %s', 'wpactpro-estimated-delivery-date' ) );
		if ( '' === $msg ) {
			/* translators: %s: delivery date or date range. */
			$msg = __( 'Overall estimated delivery: %s', 'wpactpro-estimated-delivery-date' );
		}
		$html = sprintf( $msg, $date );
		$html = '<span class="edfw-delivery edfw-cart-overall">' . esc_html( $html ) . '</span>';

		/**
		 * Filter the cart's overall estimate markup.
		 *
		 * @since 1.0.0
		 * @param string $html Rendered HTML.
		 */
		return (string) apply_filters( 'edfw_cart_overall_html', $html );
	}

	/**
	 * Print the overall estimate row in the checkout order review.
	 *
	 * @since 1.0.0
	 */
	public function checkout_overall(): void {
		$html = $this->get_cart_overall_html();
		if ( '' === $html ) {
			return;
		}
		echo '<tr><td colspan="2" class="edfw-checkout-overall">' . wp_kses_post( $html ) . '</td></tr>';
	}

	/**
	 * Freeze the item's estimate into order item meta at checkout, so emails
	 * and order screens show the estimate from purchase time.
	 *
	 * @since 1.0.0
	 * @param WC_Order_Item_Product $item          Order line item.
	 * @param string                $cart_item_key Cart item key.
	 * @param array                 $values        Cart item values.
	 */
	public function add_order_item_meta( $item, $cart_item_key, $values ): void {
		if ( EDFW_Backend::get_setting( 'order_item', 'yes' ) === 'no' ) {
			return;
		}
		$product = $values['data'] ?? null;
		if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
			return;
		}
		$plain = $this->get_delivery_html( $product, 'email', 'plain' );
		if ( '' !== $plain ) {
			$item->add_meta_data( '_edfw_delivery', $plain, true );
		}
	}

	/**
	 * Print the stored estimate under an order line item (emails, order screens).
	 *
	 * @since 1.0.0
	 * @param int           $item_id Order item ID.
	 * @param WC_Order_Item $item    Order item.
	 */
	public function order_item_meta( int $item_id, $item ): void {
		$delivery = $item->get_meta( '_edfw_delivery' );
		if ( '' === $delivery ) {
			return;
		}
		$label = EDFW_Backend::get_setting( 'text_cart_item', __( 'Estimated delivery', 'wpactpro-estimated-delivery-date' ) );
		/**
		 * Filter the label shown against an order line item's stored estimate.
		 *
		 * @since 1.0.0
		 * @param string $label Label.
		 * @param WC_Order_Item $item Order item.
		 */
		$label = apply_filters( 'edfw_order_item_meta_label', $label, $item );
		echo '<div class="edfw-order-meta"><strong>' . esc_html( $label ) . ':</strong> ' . esc_html( $delivery ) . '</div>';
	}

	/**
	 * Add spacing for the estimate line in order emails.
	 *
	 * @since 1.0.0
	 * @param string $css   Email CSS.
	 * @param mixed  $email Email object.
	 * @return string
	 */
	public function email_styles( string $css, $email ): string {
		$css .= "\n.edfw-order-meta { margin-top:8px; }\n";
		/**
		 * Filter the CSS added to WooCommerce order emails.
		 *
		 * @since 1.0.0
		 * @param string $css Email stylesheet.
		 * @param mixed $email Email object.
		 */
		return (string) apply_filters( 'edfw_email_styles', $css, $email );
	}
}
