<?php
/**
 * Ocultar precios y compra a no-B2B (F4-1) — la feature comercial más pedida
 * del nicho mayorista: muchas tiendas B2B/mayoristas no quieren enseñar
 * precios a cualquiera (competencia, minoristas sin cuenta) ni dejarles
 * comprar.
 *
 * El detalle (modos, cobertura y decisiones de alcance) está en el docblock de
 * la clase, más abajo: la guarda `defined( 'ABSPATH' )` tiene que caber en las
 * 50 primeras líneas o Plugin Check no la ve y da el fichero por desprotegido.
 *
 * @package Entrepo
 */

namespace Entrepo\WooCommerce;

use Entrepo\Grupos\GrupoRepository;

defined( 'ABSPATH' ) || exit;

/**
 * Cablea el ocultamiento de precios/compra a WooCommerce en runtime, según el
 * modo elegido en Ajustes.
 *
 * Tres modos, guardados en la option `entrepo_ajustes_visibilidad`
 * (`{modo: ...}`, autoload no): `publico` (por defecto, no cambia nada),
 * `ocultar_precios_invitados` (solo afecta a quien no ha iniciado sesión) y
 * `solo_b2b` (afecta a todo el que no pertenezca a un grupo B2B, invitados
 * incluidos). El admin/shop-manager (`manage_woocommerce`) NUNCA está
 * afectado, en ningún modo: tiene que poder revisar su propia tienda.
 *
 * COBERTURA (cerrado el 2026-08-06; antes era la limitación conocida que
 * documentaba el FAQ «¿La ocultación de precios cubre la API?»):
 *
 * 1. Storefront clásico — `woocommerce_get_price_html`,
 *    `woocommerce_is_purchasable` y `woocommerce_loop_add_to_cart_link`.
 * 2. Store API pública y SIN autenticar (`GET /wp-json/wc/store/v1/products`)
 *    — `filtrar_store_api()`, ver abajo.
 *
 * POR QUÉ `rest_post_dispatch` Y NO UN HOOK DE WooCommerce. El TODO que había
 * aquí proponía enganchar `woocommerce_rest_prepare_product_object` dando por
 * hecho que «dispara también para la Store API». **Es falso**: se comprobó
 * contra el código de `StoreApi\Schemas\V1\ProductSchema` y ese fichero no
 * llama a ese filtro por ningún lado — construye los importes en
 * `prepare_product_price_response()` con `get_regular_price()`,
 * `get_sale_price()` y `get_variation_prices()`. Ese filtro solo cubre la API
 * antigua `wc/v3`, así que engancharlo habría dejado el agujero real abierto.
 *
 * La Store API tampoco expone un filtro propio para RETIRAR campos (su
 * extensibilidad, `ExtendSchema`, es para AÑADIR datos). Por eso se filtra en
 * `rest_post_dispatch`, que es de WordPress core, existe siempre y no depende
 * de los internos de WooCommerce Blocks — que se reescriben a menudo.
 *
 * ALCANCE DELIBERADO, dos decisiones que conviene no revertir sin pensarlas:
 *
 * - Solo se tocan las rutas de PRODUCTOS (`self::RUTA_PRODUCTOS`), no carrito
 *   ni checkout. Vaciar importes del carrito no aporta nada (un visitante
 *   afectado no puede comprar: `filtrar_purchasable()` lo impide) y sí podría
 *   romper el JS de los bloques.
 * - `wc/v3` NO se toca, aunque el TODO lo sugería. Esa API exige claves de
 *   API autenticadas: quien la consulta es el propio comerciante o su ERP, y
 *   ocultarle los precios rompería integraciones legítimas. El agujero era la
 *   API pública, no la autenticada.
 */
final class VisibilidadHooks {

	/** Option donde vive el modo elegido (autoload no: se lee poco, solo aquí y en el panel). */
	public const OPTION = 'entrepo_ajustes_visibilidad';

	/** Modo público: tienda normal, nadie está afectado (coste cero — `register()` no engancha nada). */
	public const MODO_PUBLICO = 'publico';

	/** Modo que oculta precios/compra SOLO a quien no ha iniciado sesión. */
	public const MODO_OCULTAR_INVITADOS = 'ocultar_precios_invitados';

	/** Modo que oculta precios/compra a todo el que no pertenezca a un grupo B2B (invitados incluidos). */
	public const MODO_SOLO_B2B = 'solo_b2b';

	/** Allowlist de los únicos 3 modos válidos — la usa también `AjustesController` para sanear el POST. */
	public const MODOS_VALIDOS = array( self::MODO_PUBLICO, self::MODO_OCULTAR_INVITADOS, self::MODO_SOLO_B2B );

	/**
	 * Rutas de la Store API que se filtran: SOLO productos. Se acepta cualquier
	 * versión (`v1`, y las que vengan) y también la forma sin versión.
	 */
	private const RUTA_PRODUCTOS = '#^/wc/store/(v\d+/)?products#';

	/**
	 * Claves del payload que llevan un importe y hay que vaciar. Cubre el
	 * objeto `prices` (`price`, `regular_price`, `sale_price`) y las
	 * agregaciones de rango (`min_price`, `max_price`), estén al nivel que
	 * estén: el limpiador recorre el árbol, así que también alcanza
	 * `prices.raw_prices` y cualquier anidado equivalente.
	 */
	private const CAMPOS_IMPORTE = array( 'price', 'regular_price', 'sale_price', 'min_price', 'max_price' );

	/** Tope de profundidad del limpiador: cortafuegos ante un payload anómalo. */
	private const PROFUNDIDAD_MAX = 8;

	/**
	 * Modo con el que se registró esta instancia (fijado una vez en
	 * `register()`, para no releer la option en cada `afectado()`).
	 *
	 * @var string|null
	 */
	private ?string $modo = null;

	/**
	 * Memoización por instancia (== por request, mismo criterio que
	 * `FiscalHooks::$exento_memo` — ver su docblock para el supuesto de
	 * request-por-proceso) de si el visitante actual está afectado. `null` =
	 * todavía no calculado.
	 *
	 * @var bool|null
	 */
	private ?bool $afectado_memo = null;

	/**
	 * Engancha los callbacks a WooCommerce, SOLO si el modo elegido no es
	 * `publico` — en modo público, `register()` no toca ningún hook: coste
	 * cero para la inmensa mayoría de tiendas, que nunca activan esto.
	 */
	public function register(): void {
		$this->modo = self::modo_actual();

		if ( self::MODO_PUBLICO === $this->modo ) {
			return;
		}

		add_filter( 'woocommerce_get_price_html', array( $this, 'filtrar_price_html' ), 30, 2 );
		add_filter( 'woocommerce_is_purchasable', array( $this, 'filtrar_purchasable' ), 10, 2 );
		add_filter( 'woocommerce_loop_add_to_cart_link', array( $this, 'filtrar_add_to_cart_link' ) );
		add_filter( 'rest_post_dispatch', array( $this, 'filtrar_store_api' ), 10, 3 );
	}

	/**
	 * Callback de `rest_post_dispatch`: vacía los importes de la respuesta de
	 * la Store API de productos cuando el visitante actual está afectado, para
	 * que el endpoint público sea tan discreto como el storefront.
	 *
	 * Se sale cuanto antes en el caso normal (ruta que no es de productos), de
	 * modo que el coste para el resto de la REST API es una comprobación de
	 * expresión regular sobre la ruta.
	 *
	 * @param mixed $respuesta Respuesta ya construida (normalmente `WP_REST_Response`).
	 * @param mixed $server    Servidor REST (sin uso: la decisión no depende de él).
	 * @param mixed $peticion  Petición atendida (normalmente `WP_REST_Request`).
	 * @return mixed
	 */
	public function filtrar_store_api( $respuesta, $server, $peticion ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Firma exigida por el hook `rest_post_dispatch`.
		if ( ! $respuesta instanceof \WP_REST_Response || ! $peticion instanceof \WP_REST_Request ) {
			return $respuesta;
		}

		if ( ! preg_match( self::RUTA_PRODUCTOS, (string) $peticion->get_route() ) ) {
			return $respuesta;
		}

		if ( ! $this->afectado() ) {
			return $respuesta;
		}

		$respuesta->set_data( $this->limpiar_importes( $respuesta->get_data() ) );

		return $respuesta;
	}

	/**
	 * Recorre el payload y neutraliza todo lo que revele precio o permita
	 * comprar. Conserva el resto del producto (nombre, imágenes, stock): el
	 * catálogo se sigue pudiendo navegar, que es justo lo que hace el
	 * storefront en estos modos.
	 *
	 * La Store API mezcla arrays y `stdClass` en la misma respuesta (`prices`
	 * va como objeto), así que se tratan ambos y se devuelve cada nodo con el
	 * mismo tipo con el que entró — cambiarlo alteraría el JSON resultante.
	 *
	 * @param mixed $nodo        Nodo del payload.
	 * @param int   $profundidad Profundidad actual de la recursión.
	 * @return mixed
	 */
	private function limpiar_importes( $nodo, int $profundidad = 0 ) {
		if ( $profundidad > self::PROFUNDIDAD_MAX || ( ! is_array( $nodo ) && ! $nodo instanceof \stdClass ) ) {
			return $nodo;
		}

		$es_objeto = $nodo instanceof \stdClass;
		$campos    = $es_objeto ? get_object_vars( $nodo ) : $nodo;

		foreach ( $campos as $clave => $valor ) {
			if ( in_array( $clave, self::CAMPOS_IMPORTE, true ) && ( is_string( $valor ) || is_numeric( $valor ) ) ) {
				$campos[ $clave ] = '';
			} elseif ( 'price_range' === $clave ) {
				$campos[ $clave ] = null;
			} elseif ( 'is_purchasable' === $clave && is_bool( $valor ) ) {
				$campos[ $clave ] = false;
			} elseif ( 'on_sale' === $clave && is_bool( $valor ) ) {
				$campos[ $clave ] = false; // Estar de oferta ya insinúa el precio.
			} else {
				$campos[ $clave ] = $this->limpiar_importes( $valor, $profundidad + 1 );
			}
		}

		return $es_objeto ? (object) $campos : $campos;
	}

	/**
	 * Callback de `woocommerce_get_price_html`: si el visitante actual está
	 * afectado, sustituye el precio por un enlace de login (nunca se llega
	 * aquí en modo `publico`: `register()` no engancha el filtro).
	 *
	 * @param string $html     HTML del precio que WooCommerce iba a pintar.
	 * @param mixed  $producto Producto que se está pintando (sin uso: la decisión depende solo del visitante actual, nunca del producto).
	 * @return string
	 */
	public function filtrar_price_html( string $html, $producto ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Firma exigida por el hook `woocommerce_get_price_html`.
		if ( ! $this->afectado() ) {
			return $html;
		}

		return self::enlace_precio_oculto();
	}

	/**
	 * Callback de `woocommerce_is_purchasable`: false si el visitante actual
	 * está afectado (no puede comprar, aunque el producto en sí sea
	 * comprable).
	 *
	 * @param bool  $purchasable Valor original calculado por WooCommerce.
	 * @param mixed $producto    Producto que se está evaluando (sin uso: ver `filtrar_price_html()`).
	 * @return bool
	 */
	public function filtrar_purchasable( bool $purchasable, $producto ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Firma exigida por el hook `woocommerce_is_purchasable`.
		if ( $this->afectado() ) {
			return false;
		}

		return $purchasable;
	}

	/**
	 * Callback de `woocommerce_loop_add_to_cart_link`: vacía el botón
	 * "Añadir al carrito" del loop de producto (archivo/tienda) si el
	 * visitante actual está afectado.
	 *
	 * @param string $html Enlace/botón original que WooCommerce iba a pintar.
	 * @return string
	 */
	public function filtrar_add_to_cart_link( string $html ): string {
		return $this->afectado() ? '' : $html;
	}

	/**
	 * `true` si el visitante/usuario actual está afectado por el modo de
	 * visibilidad vigente (oculto de precios + no puede comprar). Memoizado
	 * por instancia.
	 *
	 * @return bool
	 */
	public function afectado(): bool {
		if ( null !== $this->afectado_memo ) {
			return $this->afectado_memo;
		}

		$this->afectado_memo = $this->calcular_afectado();

		return $this->afectado_memo;
	}

	/**
	 * Cálculo real de `afectado()` (sin memoización).
	 *
	 * El admin/shop-manager (`manage_woocommerce`) NUNCA está afectado, en
	 * ningún modo — no autolesionarse al revisar la propia tienda.
	 *
	 * - `ocultar_precios_invitados`: afectado solo quien NO ha iniciado
	 *   sesión (`! is_user_logged_in()`). Un cliente logueado, sea B2C o
	 *   B2B, ve todo.
	 * - `solo_b2b`: afectado todo el que no pertenezca a un grupo B2B
	 *   (`GrupoRepository::mejor_grupo_de_usuario() === ''`), invitados
	 *   incluidos.
	 *
	 * @return bool
	 */
	private function calcular_afectado(): bool {
		$modo = $this->modo ?? self::modo_actual();

		if ( self::MODO_PUBLICO === $modo ) {
			return false;
		}

		if ( current_user_can( 'manage_woocommerce' ) ) {
			return false; // El admin/shop-manager revisa su tienda: nunca afectado.
		}

		if ( self::MODO_OCULTAR_INVITADOS === $modo ) {
			return ! is_user_logged_in();
		}

		if ( self::MODO_SOLO_B2B === $modo ) {
			$user_id = get_current_user_id();
			if ( 0 === $user_id ) {
				return true; // Invitado: nunca pertenece a un grupo B2B.
			}

			return '' === ( new GrupoRepository() )->mejor_grupo_de_usuario( $user_id );
		}

		return false;
	}

	/**
	 * El enlace de login que sustituye al precio para un visitante afectado.
	 * Texto filtrable (`entrepo_texto_precio_oculto`) para que una tienda
	 * pueda personalizarlo (idioma, tono) sin tocar código.
	 *
	 * @return string
	 */
	private static function enlace_precio_oculto(): string {
		/**
		 * Filtra el texto del enlace que sustituye al precio oculto.
		 *
		 * @param string $texto Texto por defecto.
		 */
		$texto = (string) apply_filters( 'entrepo_texto_precio_oculto', __( 'Sign in to see prices', 'entrepo' ) );

		return sprintf( '<a href="%1$s">%2$s</a>', esc_url( wp_login_url() ), esc_html( $texto ) );
	}

	/**
	 * Modo de visibilidad guardado en la option, saneado con la allowlist de
	 * `MODOS_VALIDOS` — un valor inesperado (option corrupta, o todavía sin
	 * crear) cae siempre a `publico` (el modo que no cambia nada).
	 *
	 * @return string
	 */
	public static function modo_actual(): string {
		$guardado = get_option( self::OPTION, array() );
		$modo     = is_array( $guardado ) ? (string) ( $guardado['modo'] ?? self::MODO_PUBLICO ) : self::MODO_PUBLICO;

		return in_array( $modo, self::MODOS_VALIDOS, true ) ? $modo : self::MODO_PUBLICO;
	}
}
