<?php
/**
 * Computes a new price from a supplier's list price and a pricing rule (SPEC §5).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Pricing;

/**
 * Pure: VAT normalization -> margin -> rounding -> existing sale price
 * policy, in that order. The caller (Rest, per the dependency matrix)
 * resolves which rule applies to a given row (supplier default vs category
 * override) and which product's existing prices to pass in; this class
 * only knows how to turn one already-resolved rule into a price.
 *
 * Rule shape (all keys optional, sensible defaults applied):
 * ```
 * [
 *     'margin_type'       => 'percentage'|'fixed',            // default 'percentage'
 *     'margin_value'       => float,                           // ratio (0.35 = 35%) or flat amount; default 0.0
 *     'list_includes_vat'  => bool,                             // does the supplier's list price already include VAT? default false
 *     'store_includes_vat' => bool,                             // does the store display VAT-inclusive prices? default false
 *     'vat_rate'           => float,                            // ratio, e.g. 0.21; default 0.0
 *     'rounding'           => [
 *         'mode'     => 'none'|'decimals'|'psychological'|'multiple', // default 'none'
 *         'decimals' => int,                                          // 'decimals' mode; default 2
 *         'ending'   => float,                                        // 'psychological' mode: 0.99 or 0.90; default 0.99
 *         'multiple' => float,                                        // 'multiple' mode, e.g. 100; default 1.0
 *     ],
 *     'sale_price_policy'  => 'keep'|'remove_if_exceeds'|'scale_proportionally', // default 'keep'
 * ]
 * ```
 */
final class RuleEngine {

	/**
	 * Computes the new regular/sale price for one row.
	 *
	 * @param float $list_price Supplier's list price for this row.
	 * @param array $rule       Rule configuration (see class docblock).
	 * @param array $existing   The matched product's current prices, if any:
	 *                          `{ regular_price?: ?float, sale_price?: ?float }`.
	 *                          Only used by the sale price policy.
	 *
	 * @return array{regular_price: float, sale_price: ?float}
	 *
	 * @throws \InvalidArgumentException When the list price or rule is invalid.
	 */
	public function apply( float $list_price, array $rule, array $existing = array() ): array {
		if ( ! is_finite( $list_price ) || $list_price <= 0.0 ) {
			throw new \InvalidArgumentException( 'List price must be a positive finite number.' );
		}

		$rule  = ( new RuleValidator() )->validate_rule( $rule );
		$price = $this->adjust_for_vat( $list_price, $rule );
		$price = $this->apply_margin( $price, $rule );
		$price = $this->round_price( $price, (array) ( $rule['rounding'] ?? array() ) );

		if ( ! is_finite( $price ) || $price < 0.0 ) {
			throw new \InvalidArgumentException( 'Computed price is invalid.' );
		}

		return array(
			'regular_price' => $price,
			'sale_price'    => $this->resolve_sale_price( $price, $rule, $existing ),
		);
	}

	/**
	 * Converts the list price to the store's VAT convention, if it differs
	 * from how the supplier wrote it.
	 *
	 * @param float $price List price, as parsed from the supplier's row.
	 * @param array $rule  Rule configuration.
	 */
	private function adjust_for_vat( float $price, array $rule ): float {
		$list_includes_vat  = (bool) ( $rule['list_includes_vat'] ?? false );
		$store_includes_vat = (bool) ( $rule['store_includes_vat'] ?? false );

		if ( $list_includes_vat === $store_includes_vat ) {
			return $price;
		}

		$vat_rate = (float) ( $rule['vat_rate'] ?? 0.0 );

		if ( $list_includes_vat ) {
			// List has VAT baked in, but the store wants VAT-exclusive prices: strip it.
			return $price / ( 1 + $vat_rate );
		}

		// List is VAT-exclusive, but the store wants VAT-inclusive prices: add it.
		return $price * ( 1 + $vat_rate );
	}

	/**
	 * Applies the margin on top of the VAT-adjusted price.
	 *
	 * @param float $price VAT-adjusted price.
	 * @param array $rule  Rule configuration.
	 */
	private function apply_margin( float $price, array $rule ): float {
		$type  = (string) ( $rule['margin_type'] ?? 'percentage' );
		$value = (float) ( $rule['margin_value'] ?? 0.0 );

		return 'fixed' === $type ? $price + $value : $price * ( 1 + $value );
	}

	/**
	 * Rounds the price per the rule's rounding mode.
	 *
	 * @param float $price    Price after margin.
	 * @param array $rounding The rule's `rounding` sub-array.
	 */
	private function round_price( float $price, array $rounding ): float {
		$mode = (string) ( $rounding['mode'] ?? 'none' );

		if ( 'decimals' === $mode ) {
			return round( $price, (int) ( $rounding['decimals'] ?? 2 ) );
		}

		if ( 'psychological' === $mode ) {
			// e.g. 1234.56 with ending 0.99 -> 1234.99: same integer part, forced ending.
			return floor( $price ) + (float) ( $rounding['ending'] ?? 0.99 );
		}

		if ( 'multiple' === $mode ) {
			$multiple = (float) ( $rounding['multiple'] ?? 1.0 );

			return $multiple > 0.0 ? round( $price / $multiple ) * $multiple : $price;
		}

		return $price;
	}

	/**
	 * Resolves the new sale price per the rule's sale_price_policy. A
	 * product with no existing sale price never gets one invented here --
	 * this policy only manages an existing markdown, RuleEngine doesn't
	 * decide to put a product on sale.
	 *
	 * Public because Apply\BatchApplier (T-305) calls it a second time at
	 * apply time, against the product's prices as they are right then --
	 * `apply()`'s own call is only for the preview-time diff/guardrails,
	 * and prices can drift between preview and apply (e.g. a merchant
	 * editing a sale price manually in between).
	 *
	 * @param float $new_regular_price The just-computed new regular price.
	 * @param array $rule              Rule configuration.
	 * @param array $existing          `{ regular_price?: ?float, sale_price?: ?float }`.
	 *
	 * @throws \InvalidArgumentException When the rule is invalid.
	 */
	public function resolve_sale_price( float $new_regular_price, array $rule, array $existing ): ?float {
		$rule = ( new RuleValidator() )->validate_rule( $rule );

		$old_sale_price = $existing['sale_price'] ?? null;

		if ( null === $old_sale_price ) {
			return null;
		}

		$policy = (string) ( $rule['sale_price_policy'] ?? 'keep' );

		if ( 'remove_if_exceeds' === $policy ) {
			return $old_sale_price >= $new_regular_price ? null : $old_sale_price;
		}

		if ( 'scale_proportionally' === $policy ) {
			$old_regular_price = $existing['regular_price'] ?? null;

			if ( null === $old_regular_price || $old_regular_price <= 0.0 ) {
				return $old_sale_price;
			}

			return round( $new_regular_price * ( $old_sale_price / $old_regular_price ), 2 );
		}

		return $old_sale_price;
	}
}
