<?php
/**
 * Cablea el motor de precios a WooCommerce en runtime: sirve el precio neto
 * del rol del cliente en tienda y carrito, precarga el carrito para evitar
 * N+1, y dispara el recálculo de la matriz cuando se guarda un producto.
 *
 * Esta clase NUNCA calcula un precio: solo lee la matriz precalculada vía
 * `PrecioRepository::precio_para()` (ver ese fichero para el porqué). El
 * único cómputo que hace es "¿a qué rol de grupo pertenece el usuario
 * actual?", memoizado por instancia porque `filtrar_precio()` se dispara en
 * decenas/cientos de llamadas por request (listado, carrito, widgets).
 *
 * @package Entrepo
 */

namespace Entrepo\WooCommerce;

use Entrepo\Grupos\GrupoRepository;
use Entrepo\Precios\PrecioRepository;
use Entrepo\Precios\PrecioService;

defined( 'ABSPATH' ) || exit;

/**
 * Cablea el motor de precios a WooCommerce en runtime.
 *
 * Sirve el precio neto del rol del cliente en tienda y carrito, precarga el
 * carrito para evitar N+1, y dispara el recálculo de la matriz al guardar un
 * producto. Nunca calcula un precio: solo lee `PrecioRepository::precio_para()`.
 */
final class PrecioHooks {

	/**
	 * Memoización por instancia (== por request, ya que `register()` crea una
	 * sola instancia en `Plugin::boot()`) del rol de grupo del usuario actual.
	 * `null` = todavía no calculado; `''` = calculado y es B2C/invitado.
	 *
	 * @var string|null
	 */
	private ?string $rol_memo = null;

	/**
	 * Dedupe por request de `al_guardar_producto()`: `product_id => true` para
	 * cada producto ya recalculado en esta request. Estática (no de instancia)
	 * porque `al_guardar_producto()` está enganchado tanto a
	 * `woocommerce_update_product` como a `woocommerce_process_product_meta` —
	 * guardar un producto en el editor clásico dispara AMBOS hooks, y sin este
	 * guard `PrecioService::recalcular_producto()` correría dos veces (doble
	 * SELECT+DELETE+INSERT por cada guardado). Se mantienen los dos hooks
	 * (robustez: cada uno cubre un camino de guardado distinto), pero solo el
	 * primero que dispare para un producto dado ejecuta el recálculo.
	 *
	 * @var array<int,true>
	 */
	private static array $ya_recalculados = array();

	/** Engancha los callbacks a WooCommerce. */
	public function register(): void {
		add_filter( 'woocommerce_product_get_price', array( $this, 'filtrar_precio' ), 20, 2 );
		add_filter( 'woocommerce_product_variation_get_price', array( $this, 'filtrar_precio' ), 20, 2 );
		add_action( 'woocommerce_before_calculate_totals', array( $this, 'precargar_carrito' ), 5 );
		add_action( 'woocommerce_update_product', array( $this, 'al_guardar_producto' ) );
		add_action( 'woocommerce_process_product_meta', array( $this, 'al_guardar_producto' ) );
	}

	/**
	 * Rol de grupo Entrepo del usuario actual, o `''` si es B2C/invitado (no
	 * pertenece a ningún grupo gestionado por `GrupoRepository`).
	 *
	 * Delegado en `GrupoRepository::mejor_grupo_de_usuario()` (F3-5): mismo
	 * criterio de desempate (gana el de MAYOR `descuento_pct`), ahora
	 * reutilizable también para un usuario ARBITRARIO (el simulador lo
	 * necesita para "¿qué ve ESTE cliente?", no solo el actual). Antes este
	 * método tenía su propio recorrido de `wp_get_current_user()->roles`;
	 * ahora solo resuelve el ID del usuario actual (`get_current_user_id()`,
	 * `0` para invitados) y delega el cálculo.
	 *
	 * Memoizado por instancia: el resultado no cambia a mitad de un request
	 * normal (el usuario actual no cambia de rol entre llamadas), así que
	 * calcularlo una vez evita repetir el recorrido de roles/opción en cada
	 * una de las muchas llamadas a `filtrar_precio()`.
	 */
	public function rol_actual(): string {
		if ( null !== $this->rol_memo ) {
			return $this->rol_memo;
		}

		$this->rol_memo = ( new GrupoRepository() )->mejor_grupo_de_usuario( get_current_user_id() );

		return $this->rol_memo;
	}

	/**
	 * Callback de `woocommerce_product_get_price` /
	 * `woocommerce_product_variation_get_price`.
	 *
	 * Si el usuario actual pertenece a un grupo B2B y la matriz precalculada
	 * tiene un neto para (producto, variación, rol), lo sirve; si no, deja el
	 * precio de catálogo intacto. Para invitados/B2C (`rol_actual() === ''`)
	 * no se toca `PrecioRepository` en absoluto.
	 *
	 * @param mixed       $price   Precio de catálogo tal como lo da WooCommerce.
	 * @param \WC_Product $product Producto simple o variación.
	 * @return mixed El precio de catálogo, o el neto del rol formateado por WC.
	 */
	public function filtrar_precio( $price, $product ) {
		$rol = $this->rol_actual();
		if ( '' === $rol ) {
			return $price;
		}

		// El producto variable PADRE no tiene precio propio: su neto B2B vive
		// en las filas de sus variaciones (variation_id != 0), nunca en una fila
		// a nivel de producto. Sin este guard, precio_para(parent_id, 0, rol)
		// devuelve una fila espuria (0.0), corrompiendo el precio que ven
		// consumidores como WC_Structured_Data (JSON-LD) para el padre. Deja el
		// precio de catalogo intacto.
		if ( $product->is_type( 'variable' ) ) {
			return $price;
		}

		if ( $product->is_type( 'variation' ) ) {
			$product_id   = $product->get_parent_id();
			$variation_id = $product->get_id();
		} else {
			$product_id   = $product->get_id();
			$variation_id = 0;
		}

		$neto = ( new PrecioRepository() )->precio_para( $product_id, $variation_id, $rol );
		if ( null === $neto ) {
			return $price;
		}

		// El neto de la matriz se precalcula sobre el precio REGULAR
		// (`PrecioService::recalcular_producto()` nunca mira si el producto está
		// en oferta). Si el producto está en oferta, `$price` que llega aquí YA
		// es el precio de oferta activo (WooCommerce se lo pasa así al filtro
		// `woocommerce_product_get_price` / `..._variation_get_price`); si no
		// está en oferta, `$price` es el regular. Sin este tope, un producto en
		// oferta (regular 100, oferta 50) con un grupo al 20 % (neto 80) haría
		// que el mayorista pagase MÁS (80) que el público (50) — inaceptable.
		// Por eso el neto B2B NUNCA debe superar el precio público vigente: se
		// sirve el neto solo si es estrictamente menor que `$price`; si no, se
		// deja `$price` intacto y el cliente B2B se lleva la mejor de las dos
		// ofertas. NO se toca `recalcular_producto()`: la matriz sigue
		// guardando el neto sobre el regular; el tope se aplica aquí, al servir.
		return ( (float) $neto < (float) $price ) ? wc_format_decimal( $neto ) : $price;
	}

	/**
	 * Callback de `woocommerce_before_calculate_totals`.
	 *
	 * Precarga en memoria el precio del rol actual para TODOS los productos
	 * del carrito con una sola consulta por lotes (`PrecioRepository::precargar()`),
	 * para que las llamadas a `filtrar_precio()` que WooCommerce dispara al
	 * recalcular totales salgan de memoria en vez de hacer un SELECT por línea.
	 *
	 * @param mixed $cart Objeto con `get_cart(): array` (en producción, `WC_Cart`).
	 */
	public function precargar_carrito( $cart ): void {
		$rol = $this->rol_actual();
		if ( '' === $rol ) {
			return;
		}

		$product_ids = array();
		foreach ( $cart->get_cart() as $item ) {
			$product_ids[] = (int) $item['product_id'];
		}

		if ( empty( $product_ids ) ) {
			return;
		}

		( new PrecioRepository() )->precargar( $product_ids, $rol );
	}

	/**
	 * Callback de `woocommerce_update_product` / `woocommerce_process_product_meta`.
	 *
	 * Recalcula y persiste la matriz de precios del producto guardado. Dedupe
	 * por request vía `$ya_recalculados`: ver el comentario de esa propiedad.
	 *
	 * @param int $product_id ID del producto guardado.
	 */
	public function al_guardar_producto( int $product_id ): void {
		if ( isset( self::$ya_recalculados[ $product_id ] ) ) {
			return;
		}
		self::$ya_recalculados[ $product_id ] = true;

		PrecioService::recalcular_producto( $product_id );
	}
}
