<?php
/**
 * Orquesta el recálculo masivo de la matriz de precios por LOTES, en segundo
 * plano, vía Action Scheduler.
 *
 * @package Entrepo
 */

namespace Entrepo\Infra;

use Entrepo\Precios\PrecioService;

defined( 'ABSPATH' ) || exit;

/**
 * Job de recálculo masivo de `entrepo_precios` (F3-3): cierra el hueco
 * latente documentado en `GrupoRepository::actualizar()` — crear un grupo o
 * cambiar su % dejaba la matriz obsoleta hasta re-guardar cada producto a
 * mano.
 *
 * Recalcular el catálogo entero dentro de la misma request REST no es viable
 * (timeout/OOM en una tienda grande), así que el trabajo se trocea en lotes
 * de `LOTE` productos y cada lote es una acción de Action Scheduler (lo trae
 * WooCommerce) que, al terminar, encola la siguiente. El progreso se
 * persiste en la opción `OPTION` para que el panel (`RecalcBanner.js`) lo
 * consulte por polling sin depender de Action Scheduler expuesto al
 * frontend.
 *
 * Token de ciclo + TTL por actividad (un solo mecanismo, dos problemas):
 * cada `encolar()` genera un `ciclo` (`uniqid()`) que viaja como argumento
 * de la acción de Action Scheduler y que `procesar_lote()` compara contra
 * el `ciclo` guardado en el estado ANTES de tocar nada. Esto hace inofensivo
 * cualquier lote huérfano — de un proceso caído a mitad (deploy, fatal
 * error, Action Scheduler purgado) o de la ventana de carrera en la que dos
 * `encolar()` casi simultáneos leen `en_curso=false` a la vez: el segundo
 * `update_option` gana y escribe un ciclo nuevo, así que los lotes que el
 * primer `encolar()` ya había encolado se descartan solos al no coincidir
 * el token, sin que nadie tenga que coordinar nada explícitamente. Y para
 * que el lock (`en_curso`) no quede pegado para siempre cuando el proceso
 * que debía terminarlo ya no existe, `procesar_lote()` refresca un
 * timestamp `actualizado` en cada lote; `encolar()` solo respeta el lock si
 * hubo actividad hace menos de `TTL_HUERFANO` segundos — pasado ese tiempo
 * sin noticias, el job se da por muerto y el lock se recupera solo.
 */
final class RecalcJob {

	/** Hook de Action Scheduler que procesa un lote. */
	public const HOOK = 'entrepo_recalc_lote';

	/** Opción donde se persiste el estado del recálculo en curso. */
	public const OPTION = 'entrepo_recalc_estado';

	/** Tamaño de lote: productos que recalcula cada acción encolada. */
	public const LOTE = 25;

	/**
	 * Tope duro de productos para el modo fallback (sin Action Scheduler):
	 * evita reventar la request procesando un catálogo grande de golpe. Ver
	 * el porqué completo en `encolar()`.
	 */
	private const TOPE_FALLBACK = 200;

	/**
	 * TTL de inactividad (segundos) para dar un recálculo `en_curso` por
	 * muerto. NO es un tope a la duración total del job — un catálogo
	 * grande puede tardar más de esto y sigue siendo válido mientras
	 * `procesar_lote()` siga refrescando `actualizado`; es un tope a cuánto
	 * tiempo puede pasar SIN actividad antes de asumir que el proceso que
	 * debía continuarlo ya no existe.
	 */
	private const TTL_HUERFANO = 600;

	/**
	 * Engancha `procesar_lote()` al hook de Action Scheduler.
	 *
	 * Se llama SIEMPRE desde `Plugin::boot()` (no solo en `is_admin()`): los
	 * lotes los ejecuta el runner de Action Scheduler (cron/AS-Async-Request),
	 * que no es una pantalla de administración.
	 */
	public static function register(): void {
		add_action( self::HOOK, array( self::class, 'procesar_lote' ), 10, 2 );
	}

	/**
	 * Encola el recálculo masivo del catálogo completo.
	 *
	 * Lock anti-solape: si ya hay un recálculo `en_curso` con actividad
	 * reciente (menos de `TTL_HUERFANO` segundos desde el último
	 * `procesar_lote()`), no encola uno nuevo — sin esto, crear/editar
	 * varios grupos seguidos encolaría lotes duplicados que se pisarían
	 * entre sí (mismo hook, páginas repetidas).
	 *
	 * Recuperación de lock huérfano: si `en_curso` pero la última actividad
	 * fue hace MÁS de `TTL_HUERFANO`, el job se da por muerto (proceso
	 * caído a mitad, deploy, fatal error, Action Scheduler purgado...) — sin
	 * esto el lock quedaría pegado para siempre y los cambios de % dejarían
	 * de recalcular en silencio. Se cancelan las acciones AS que pudieran
	 * quedar del ciclo viejo (se descartarían solas por el token de ciclo
	 * de todos modos; cancelarlas solo evita ruido) y se encola un ciclo
	 * nuevo, guardando de qué ciclo se recuperó para diagnóstico.
	 *
	 * Cada llamada genera un `ciclo` nuevo (`uniqid()`) que viaja con la
	 * acción de Action Scheduler; `procesar_lote()` lo compara contra el
	 * estado antes de tocar nada. Esto es lo que hace inofensiva la ventana
	 * de carrera en la que dos `encolar()` casi simultáneos leen
	 * `en_curso=false` a la vez: ambos pasan el lock y escriben, el segundo
	 * `update_option` gana, y los lotes que el primero ya había encolado se
	 * descartan solos al no coincidir el ciclo — no corrompen el contador
	 * del ciclo que sí ganó. Ver el docblock de la clase.
	 *
	 * Guarda el estado inicial (autoload=false: es un dato operativo, no algo
	 * que WordPress necesite cargar en cada request) y programa el primer
	 * lote.
	 *
	 * Fallback sin Action Scheduler (entorno sin WooCommerce activo, o AS
	 * desactivado): degrada a un recálculo SÍNCRONO acotado a
	 * `TOPE_FALLBACK` productos. Es preferible a no recalcular nada; el tope
	 * evita reventar la request en un catálogo grande. Sin Action Scheduler
	 * no hay quién reanude el resto en segundo plano, así que el estado se
	 * cierra como terminado en vez de fingir que "sigue en curso" para
	 * siempre.
	 *
	 * @param string $motivo Motivo del recálculo, para el estado/log (p. ej. `grupo:<role>`).
	 * @return bool `true` si se encoló (o se completó vía fallback); `false` si ya había uno en curso con actividad reciente (lock vivo).
	 */
	public static function encolar( string $motivo = '' ): bool {
		$estado_actual = self::estado();
		$recuperado_de = null;

		if ( ! empty( $estado_actual['en_curso'] ) ) {
			$ultima_actividad = (int) ( $estado_actual['actualizado'] ?? $estado_actual['iniciado_ts'] ?? 0 );
			if ( ( time() - $ultima_actividad ) <= self::TTL_HUERFANO ) {
				return false;
			}

			if ( function_exists( 'as_unschedule_all_actions' ) ) {
				as_unschedule_all_actions( self::HOOK );
			}
			$recuperado_de = $estado_actual['ciclo'] ?? '';
		}

		$ciclo = uniqid( 'recalc_', true );
		$ahora = time();

		$estado_nuevo = array(
			'en_curso'    => true,
			'ciclo'       => $ciclo,
			'motivo'      => $motivo,
			'total'       => PrecioService::contar_productos(),
			'procesados'  => 0,
			'pagina'      => 1,
			'iniciado'    => current_time( 'mysql' ),
			'iniciado_ts' => $ahora,
			'actualizado' => $ahora,
		);
		if ( null !== $recuperado_de ) {
			$estado_nuevo['recuperado_de'] = $recuperado_de;
		}

		update_option( self::OPTION, $estado_nuevo, false );

		if ( ! function_exists( 'as_enqueue_async_action' ) ) {
			self::procesar_fallback_sincrono();
			return true;
		}

		as_enqueue_async_action( self::HOOK, array( 1, $ciclo ), 'entrepo' );
		return true;
	}

	/**
	 * Procesa un lote del catálogo y encola el siguiente si queda catálogo
	 * por recorrer, o marca el recálculo como terminado si no.
	 *
	 * Idempotente y reanudable: reprocesar la misma página solo reescribe
	 * las mismas filas de `entrepo_precios` (mismo cálculo, mismo resultado),
	 * así que un lote duplicado (p. ej. un reintento de Action Scheduler) no
	 * corrompe nada ni desincroniza el contador — cada llamada solo suma lo
	 * que `recalcular_lote()` procesó en ESA página.
	 *
	 * Guard de ciclo huérfano: si `$ciclo` no coincide con el del estado (o
	 * ya no hay recálculo `en_curso`), esta acción es de un ciclo que ya
	 * terminó, fue cancelado, o fue recuperado por TTL en `encolar()` —
	 * se descarta SIN tocar el estado ni encolar nada más. Ver el docblock
	 * de la clase.
	 *
	 * @param int    $pagina Página del catálogo a procesar (base 1).
	 * @param string $ciclo  Token del ciclo al que pertenece esta acción (viene de `encolar()`).
	 */
	public static function procesar_lote( int $pagina, string $ciclo = '' ): void {
		$estado = self::estado();
		if ( empty( $estado['en_curso'] ) || ( $estado['ciclo'] ?? '' ) !== $ciclo ) {
			return;
		}

		$resultado = PrecioService::recalcular_lote( $pagina, self::LOTE );

		$estado                = self::estado();
		$estado['en_curso']    = true;
		$estado['procesados']  = (int) ( $estado['procesados'] ?? 0 ) + $resultado['procesados'];
		$estado['pagina']      = $pagina;
		$estado['actualizado'] = time();
		update_option( self::OPTION, $estado, false );

		if ( $resultado['hay_mas'] ) {
			as_enqueue_async_action( self::HOOK, array( $pagina + 1, $ciclo ), 'entrepo' );
			return;
		}

		self::marcar_terminado();
	}

	/**
	 * Estado actual del recálculo, o `['en_curso' => false]` si todavía no
	 * se ha encolado ninguno (no existe la opción).
	 *
	 * @return array<string,mixed>
	 */
	public static function estado(): array {
		$estado = get_option( self::OPTION, array() );
		if ( ! is_array( $estado ) || empty( $estado ) ) {
			return array( 'en_curso' => false );
		}
		return $estado;
	}

	/**
	 * Fallback sin Action Scheduler: recorre el catálogo síncronamente hasta
	 * agotarlo o llegar a `TOPE_FALLBACK` productos, actualizando el estado
	 * lote a lote, y cierra el estado como terminado al acabar.
	 */
	private static function procesar_fallback_sincrono(): void {
		$pagina = 1;
		while ( true ) {
			$resultado = PrecioService::recalcular_lote( $pagina, self::LOTE );

			$estado               = self::estado();
			$estado['procesados'] = (int) ( $estado['procesados'] ?? 0 ) + $resultado['procesados'];
			$estado['pagina']     = $pagina;
			update_option( self::OPTION, $estado, false );

			if ( ! $resultado['hay_mas'] || $pagina * self::LOTE >= self::TOPE_FALLBACK ) {
				break;
			}
			++$pagina;
		}

		self::marcar_terminado();
	}

	/** Marca el recálculo como terminado (`en_curso:false` + timestamp). */
	private static function marcar_terminado(): void {
		$estado              = self::estado();
		$estado['en_curso']  = false;
		$estado['terminado'] = current_time( 'mysql' );
		update_option( self::OPTION, $estado, false );
	}
}
