<?php
/**
 * Acceso a la matriz de precios precalculada (`entrepo_precios`).
 *
 * El motor NO calcula precios en runtime: los lee. Toda la lógica cara (aplicar
 * el % de grupo, el precio fijo por producto, etc.) ocurre al GUARDAR, y aquí
 * solo se resuelve la fila más específica con un lookup indexado + memoización
 * por request. Esto es lo que evita el desastre documentado de los líderes
 * (carrito que se congela, categorías a 8-16 s, cientos de miles de reglas).
 *
 * @package Entrepo
 */

namespace Entrepo\Precios;

use Entrepo\Persistencia\Migrations;

defined( 'ABSPATH' ) || exit;

/**
 * Repositorio de la matriz de precios precalculada (`entrepo_precios`).
 *
 * Solo lee/escribe la tabla propia del plugin: nunca calcula el precio neto
 * (eso es `PrecioService`), solo resuelve o persiste filas ya calculadas.
 */
final class PrecioRepository {

	/**
	 * Caché por request: role => product_id => [ variation_id => price ].
	 * Evita repetir el lookup cuando el mismo producto se pide varias veces
	 * en la misma página (listado + carrito + widgets).
	 *
	 * @var array<string,array<int,array<int,float>>>
	 */
	private static array $memo = array();

	/**
	 * Resuelve el precio de la fila MÁS ESPECÍFICA entre las candidatas.
	 *
	 * Precedencia: un precio propio de la variación gana al precio a nivel de
	 * producto (variation_id = 0). Función pura y sin BD: es el corazón testable
	 * del motor.
	 *
	 * @param array<int,array{variation_id:int,price:float}> $filas Filas de un mismo (product_id, role).
	 * @param int                                            $variation_id Variación pedida (0 si producto simple).
	 * @return float|null Precio neto, o null si no hay precio para ese rol.
	 */
	public static function resolver( array $filas, int $variation_id ): ?float {
		$nivel_producto = null;

		foreach ( $filas as $fila ) {
			$vid = (int) $fila['variation_id'];
			if ( $vid === $variation_id && 0 !== $variation_id ) {
				// Coincidencia exacta de variación: es lo más específico posible.
				return (float) $fila['price'];
			}
			if ( 0 === $vid ) {
				$nivel_producto = (float) $fila['price'];
			}
		}

		return $nivel_producto;
	}

	/**
	 * Precio neto para (producto, variación, rol), o null si no hay precio B2B.
	 *
	 * Un único SELECT indexado por (product_id, role) + memoización. Nunca
	 * recalcula: solo lee la matriz precalculada.
	 *
	 * @param int    $product_id   ID del producto.
	 * @param int    $variation_id ID de la variación (0 si es producto simple).
	 * @param string $role         Rol/grupo B2B del usuario.
	 * @return float|null
	 */
	public function precio_para( int $product_id, int $variation_id, string $role ): ?float {
		if ( ! isset( self::$memo[ $role ][ $product_id ] ) ) {
			self::$memo[ $role ][ $product_id ] = $this->cargar( $product_id, $role );
		}

		$filas = self::$memo[ $role ][ $product_id ];
		if ( empty( $filas ) ) {
			return null;
		}

		return self::resolver( $filas, $variation_id );
	}

	/**
	 * Carga todas las filas de precio de un producto para un rol (lookup indexado).
	 *
	 * @param int    $product_id ID del producto.
	 * @param string $role       Rol/grupo B2B del usuario.
	 * @return array<int,array{variation_id:int,price:float}>
	 */
	private function cargar( int $product_id, string $role ): array {
		global $wpdb;

		// esc_sql(): no-op sobre el nombre de tabla propio, pero lo marca como
		// escapado para el análisis estático de Plugin Check.
		$tabla = esc_sql( Migrations::precios_table_name() );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lectura de la tabla propia del plugin; la memoización de $memo (arriba) hace de cache por request, el cache de objeto de WP no aplica a una matriz precalculada que se invalida por producto/rol.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$tabla} es el nombre de la tabla propia (viene de $wpdb->prefix, no de entrada de usuario); los valores de usuario ya van por %d/%s en $wpdb->prepare().
				"SELECT variation_id, price FROM {$tabla} WHERE product_id = %d AND role = %s",
				$product_id,
				$role
			),
			ARRAY_A
		);

		return array_map(
			static function ( $r ) {
				return array(
					'variation_id' => (int) $r['variation_id'],
					'price'        => (float) $r['price'],
				);
			},
			$rows ? $rows : array()
		);
	}

	/**
	 * Precarga en memoria los precios de MUCHOS productos con UNA sola consulta.
	 *
	 * Un carrito mayorista puede tener cientos de líneas; resolverlas con un
	 * lookup por producto sería N+1 (cientos de consultas). Aquí se traen todas
	 * de golpe con `product_id IN (...)` y se rellena la memoización — incluidos
	 * los productos SIN precio B2B (array vacío), para que `precio_para` no vuelva
	 * a consultar por ellos. Llamar antes de recorrer el carrito/listado.
	 *
	 * @param int[]  $product_ids IDs de producto a precargar.
	 * @param string $role        Rol/grupo B2B del usuario.
	 */
	public function precargar( array $product_ids, string $role ): void {
		global $wpdb;

		$faltan = array();
		foreach ( array_unique( array_map( 'intval', $product_ids ) ) as $pid ) {
			if ( ! isset( self::$memo[ $role ][ $pid ] ) ) {
				$faltan[] = $pid;
			}
		}
		if ( empty( $faltan ) ) {
			return;
		}

		// Inicializa todos a vacío: los que no tengan fila quedan cacheados como
		// "sin precio" y no se vuelven a consultar.
		foreach ( $faltan as $pid ) {
			self::$memo[ $role ][ $pid ] = array();
		}

		// esc_sql(): ver la nota en cargar().
		$tabla        = esc_sql( Migrations::precios_table_name() );
		$placeholders = implode( ',', array_fill( 0, count( $faltan ), '%d' ) );
		$params       = array_merge( $faltan, array( $role ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Lectura de la tabla propia del plugin; la memoización de $memo (arriba) hace de cache por request, el cache de objeto de WP no aplica a una matriz precalculada que se invalida por producto/rol.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$tabla} y {$placeholders} son el nombre de tabla propia y una lista de marcadores %d propios (no entrada de usuario); los valores de usuario ya van por $wpdb->prepare().
				"SELECT product_id, variation_id, price FROM {$tabla} WHERE product_id IN ({$placeholders}) AND role = %s",
				$params
			),
			ARRAY_A
		);

		foreach ( $rows ? $rows : array() as $r ) {
			self::$memo[ $role ][ (int) $r['product_id'] ][] = array(
				'variation_id' => (int) $r['variation_id'],
				'price'        => (float) $r['price'],
			);
		}
	}

	/**
	 * Reemplaza ATÓMICAMENTE todas las filas de precio de un producto:
	 * borra las existentes y escribe `$filas` de una vez. Es el único punto
	 * de escritura de la matriz — lo usa `PrecioService::recalcular_producto()`
	 * tras calcular el precio neto de cada (variación, rol). Si `$filas` está
	 * vacío, el efecto es borrar el precio B2B del producto (deja de tener
	 * fila y `precio_para()` volverá a devolver null para él).
	 *
	 * El DELETE purga las filas obsoletas de este producto (p. ej. una
	 * variación borrada), pero el INSERT es un UPSERT (`ON DUPLICATE KEY
	 * UPDATE`), no un `$wpdb->insert()` por fila: dos escritores concurrentes
	 * del MISMO producto (un lote de `RecalcJob` y un guardado de override
	 * desde el panel, por ejemplo) pueden intercalar su DELETE y su INSERT, de
	 * forma que el INSERT de un escritor choque contra la fila que el OTRO ya
	 * dejó para la misma clave única `role_prod_var` (role, product_id,
	 * variation_id). `$wpdb->insert()` ante esa colisión falla EN SILENCIO (ni
	 * excepción ni `WP_Error`, solo `false`): el panel decía "guardado" pero
	 * la fila que quedaba en BD podía ser la del OTRO escritor, no la de esta
	 * request. Con `ON DUPLICATE KEY UPDATE`, la colisión se convierte en un
	 * UPDATE explícito: la fila queda SIEMPRE con los valores de esta
	 * escritura, nunca se pierde en silencio.
	 *
	 * @param int                                                        $product_id ID del producto cuyas filas se reemplazan.
	 * @param array<int,array{variation_id:int,role:string,price:float}> $filas      Filas nuevas a escribir (vacío para borrar el precio B2B del producto).
	 */
	public function guardar_producto( int $product_id, array $filas ): void {
		global $wpdb;

		// esc_sql(): ver la nota en cargar().
		$tabla = esc_sql( Migrations::precios_table_name() );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Escritura en la tabla propia del plugin; se invalida explícitamente con reset_memo() debajo (el cache de objeto de WP no aplica); {$tabla} viene de $wpdb->prefix (no de entrada de usuario), product_id ya va por %d en $wpdb->prepare().
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$tabla} WHERE product_id = %d", $product_id ) );

		if ( empty( $filas ) ) {
			self::reset_memo();
			return;
		}

		$ahora      = current_time( 'mysql' );
		$marcadores = array();
		$valores    = array();
		foreach ( $filas as $fila ) {
			$marcadores[] = '(%d, %d, %s, %f, %s)';
			$valores[]    = $product_id;
			$valores[]    = (int) $fila['variation_id'];
			$valores[]    = (string) $fila['role'];
			$valores[]    = (float) $fila['price'];
			$valores[]    = $ahora;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Escritura en la tabla propia del plugin; se invalida explícitamente con reset_memo() debajo (el cache de objeto de WP no aplica).
		$wpdb->query(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- {$tabla} viene de $wpdb->prefix (no de entrada de usuario). $marcadores es una lista de literales '(%d, %d, %s, %f, %s)' propios, repetida una vez por fila de $filas (no entrada de usuario): el sniff no puede verificar estáticamente que el nº de placeholders generado en runtime cuadra con $valores, pero cuadra — 5 marcadores por fila, 5 valores por la misma fila, mismo orden. Los valores de usuario (variation_id, role, price) van todos por %d/%s/%f en $wpdb->prepare().
				"INSERT INTO {$tabla} (product_id, variation_id, role, price, updated_at) VALUES " . implode( ', ', $marcadores ) . ' ON DUPLICATE KEY UPDATE price = VALUES(price), updated_at = VALUES(updated_at)',
				$valores
			)
		);

		self::reset_memo();
	}

	/**
	 * Borra TODAS las filas de precio de un rol (todos los productos). Se usa
	 * al eliminar un grupo B2B (`GrupoRepository::eliminar()`): sin esto, las
	 * filas del rol borrado quedarían huérfanas en `entrepo_precios` y, si el
	 * slug se reutiliza para un grupo nuevo, `precio_para()` serviría precios
	 * del grupo viejo bajo el nombre del nuevo. El índice `role_prod_var` lidera
	 * con `role`, así que el DELETE por rol usa índice (igual que la consulta
	 * caliente).
	 *
	 * @param string $role Rol/grupo B2B cuyas filas se borran.
	 */
	public static function eliminar_rol( string $role ): void {
		global $wpdb;

		// esc_sql(): ver la nota en cargar().
		$tabla = esc_sql( Migrations::precios_table_name() );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Borrado en la tabla propia del plugin; se invalida explícitamente con reset_memo() justo debajo (el cache de objeto de WP no aplica); {$tabla} viene de $wpdb->prefix, role ya va por %s en $wpdb->prepare().
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$tabla} WHERE role = %s", $role ) );

		self::reset_memo();
	}

	/** Limpia la memoización (tras guardar precios en la misma request). */
	public static function reset_memo(): void {
		self::$memo = array();
	}
}
