<?php
/**
 * Writes one batch of a job's priced rows to their matched products.
 *
 * @package EmberPrice
 */

namespace EmberPrice\Apply;

defined( 'ABSPATH' ) || exit;

use EmberPrice\Core\Rows;
use EmberPrice\Pricing\RuleEngine;

/**
 * SPEC §6 step 2, per row: `wc_get_product`, `set_regular_price` (the
 * frozen `new_price` computed by `JobsController::preview_job()`) plus the
 * sale price policy, `save()`, `wc_delete_product_transients`. The sale
 * price is recomputed here (not read back from the preview) because it has
 * to reflect the product's prices as they are right now, at apply time --
 * see `RuleEngine::resolve_sale_price()`'s docblock. A row whose product
 * can't be resolved, or whose write throws, is flagged and skipped: it
 * never aborts the rest of the batch, and the job still finishes.
 */
final class BatchApplier {

	/**
	 * Row storage.
	 *
	 * @var Rows
	 */
	private Rows $rows;

	/**
	 * Resolves which rule (default or category override) applies to a row's product.
	 *
	 * @var RuleResolver
	 */
	private RuleResolver $rule_resolver;

	/**
	 * Only used here for its sale price policy.
	 *
	 * @var RuleEngine
	 */
	private RuleEngine $rule_engine;

	/**
	 * Constructor.
	 *
	 * @param Rows|null         $rows          Defaults to a new instance.
	 * @param RuleResolver|null $rule_resolver Defaults to a new instance.
	 * @param RuleEngine|null   $rule_engine   Defaults to a new instance.
	 */
	public function __construct( ?Rows $rows = null, ?RuleResolver $rule_resolver = null, ?RuleEngine $rule_engine = null ) {
		$this->rows          = $rows ?? new Rows();
		$this->rule_resolver = $rule_resolver ?? new RuleResolver();
		$this->rule_engine   = $rule_engine ?? new RuleEngine();
	}

	/**
	 * Applies every row in the batch, continuing past individual failures.
	 *
	 * @param array $rows         Batch of rows (auto/confirmed, already priced by preview_job).
	 * @param array $default_rule Supplier's `{ rule, category_overrides }`.
	 *
	 * @return array{applied: int, failed: int}
	 */
	public function apply_batch( array $rows, array $default_rule ): array {
		$applied = 0;
		$failed  = 0;

		foreach ( $rows as $row ) {
			if ( $this->apply_row( $row, $default_rule ) ) {
				++$applied;
			} else {
				++$failed;
			}
		}

		return array(
			'applied' => $applied,
			'failed'  => $failed,
		);
	}

	/**
	 * Writes one row's frozen price to its matched product/variation.
	 *
	 * @param array $row          A row with match_product_id/match_variation_id/new_price set.
	 * @param array $default_rule Supplier's `{ rule, category_overrides }`.
	 */
	private function apply_row( array $row, array $default_rule ): bool {
		if ( null === $row['match_product_id'] || null === $row['new_price'] || ! function_exists( 'wc_get_product' ) ) {
			$this->rows->add_flag( (int) $row['id'], 'apply_failed_product_not_found' );
			return false;
		}

		$product = wc_get_product( $row['match_variation_id'] ?? $row['match_product_id'] );

		if ( ! $product instanceof \WC_Product ) {
			$this->rows->add_flag( (int) $row['id'], 'apply_failed_product_not_found' );
			return false;
		}

		try {
			$rule = $this->rule_resolver->resolve_for_product( $default_rule, (int) $row['match_product_id'] );

			$old_regular_price = '' !== $product->get_regular_price() ? (float) $product->get_regular_price() : null;
			$old_sale_price    = '' !== $product->get_sale_price() ? (float) $product->get_sale_price() : null;
			$preview_price     = (float) ( $row['old_price'] ?? 0.0 );
			$current_price     = $old_regular_price ?? 0.0;

			if ( abs( $current_price - $preview_price ) > 0.0001 ) {
				$this->rows->add_flag( (int) $row['id'], 'apply_failed_stale_preview' );
				return false;
			}

			if ( ! is_finite( (float) $row['new_price'] ) || (float) $row['new_price'] < 0.0 ) {
				$this->rows->add_flag( (int) $row['id'], 'apply_failed_invalid_price' );
				return false;
			}

			$new_sale_price = $this->rule_engine->resolve_sale_price(
				(float) $row['new_price'],
				$rule,
				array(
					'regular_price' => $old_regular_price,
					'sale_price'    => $old_sale_price,
				)
			);

			$product->set_regular_price( (string) $row['new_price'] );
			$product->set_sale_price( null !== $new_sale_price ? (string) $new_sale_price : '' );
			$product->save();

			wc_delete_product_transients( $product->get_id() );
		} catch ( \Throwable $exception ) {
			$this->rows->add_flag( (int) $row['id'], 'apply_failed_exception' );
			return false;
		}

		return true;
	}
}
