<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\Service;

use SiteFort\Includes\SettingsStore;

if ( ! defined( 'ABSPATH' ) ) { exit; }


/**
 * ResourcePressure
 *
 * Centralized pressure computation for scanner phases.
 *
 */
class ResourcePressure {

	private const DEFAULT_MEMORY_LIMIT = 80;
	private const QUEUE_CRITICAL = 50;
	private const QUEUE_OPTIMAL = 20;
	private const BUFFER_MODERATE = 20971520;
	// Asymmetric EMA: react fast to rising pressure, slow to release.
	// Prevents premature exit from elevated states due to single clean readings.
	private const EMA_ALPHA_UP   = 0.7;
	private const EMA_ALPHA_DOWN = 0.3;
	private const CRITICAL_OFF_RATIO = 0.70;
	private const MODERATE_ON_RATIO  = 0.85;
	private const OPTIMAL_MEM_RATIO  = 0.80;
	// Absolute free-memory floor. Triggers moderate regardless of ratio.
	// Protects constrained hosts (e.g. 64MB PHP limit) where the cap leaves
	// very little headroom in absolute terms.
	private const MIN_FREE_MEMORY_MB = 20;

	/**
	 * Compute pressure state from metrics and external signals.
	 *
	 * CPU load average (sys_getloadavg) is intentionally excluded from pressure
	 * decisions. On shared hosting it reflects the entire server's aggregate load
	 * across all tenants, not this process's usage - making it an unreliable and
	 * frequently misleading signal that causes false CRITICAL on busy servers.
	 * Pressure is derived from memory % (process-specific), cloud queue depth,
	 * and upload buffer size (application-level signals).
	 *
	 * @param array $metrics Metrics from AIMD sensing.
	 * @param array $signals Optional external pressure signals.
	 * @param string|null $scan_id Scan ID for hysteresis persistence.
	 * @return array{memory_cap: int, near_limit: bool, critical: bool, moderate: bool, optimal: bool, host_pressure: bool, level: string, reasons: array<int,string>, memory_ema: float}
	 */
	public static function compute( array $metrics, array $signals = [], ?string $scan_id = null ): array {
		$limits  = self::get_limits();
		$mem_cap = $limits['memory_percent'];

		$mem    = (float) ( $metrics['memory'] ?? 0 );
		$queue  = (int)   ( $metrics['cloud_queue'] ?? 0 );
		$buffer = (int)   ( $metrics['buffer_size'] ?? 0 );

		$mem_metric = $mem;
		$state      = self::load_state( $scan_id );

		if ( $state ) {
			$mem_metric = self::ema( $mem, (float) ( $state['mem_ema'] ?? $mem ) );
		}

		$mem_ratio = $mem_cap > 0 ? ( $mem_metric / $mem_cap ) : 0;

		// Track why a level fires so the log can name the real trigger.
		$reasons = [];

		// Pressure levels driven by memory % and application-level signals only.
		$critical_now = false;
		if ( $mem_ratio >= 1.0 ) {
			$critical_now = true;
			$reasons[]    = 'memory_at_cap';
		}
		if ( $queue > self::QUEUE_CRITICAL ) {
			$critical_now = true;
			$reasons[]    = 'cloud_queue_deep';
		}

		$moderate_now = false;
		if ( $mem_ratio >= self::MODERATE_ON_RATIO ) {
			$moderate_now = true;
			$reasons[]    = 'memory_high';
		}
		if ( $buffer > self::BUFFER_MODERATE ) {
			$moderate_now = true;
			$reasons[]    = 'upload_backlog';
		}

		// Absolute free-memory floor: protect low-memory hosts where ratio
		// alone underestimates danger (e.g. 64MB limit, 60% cap = only ~18MB free).
		$free_mb = (int) ( $metrics['memory_free_mb'] ?? PHP_INT_MAX );
		if ( $free_mb < self::MIN_FREE_MEMORY_MB ) {
			$moderate_now = true;
			$reasons[]    = 'low_free_memory';
		}

		if ( ! empty( $signals['upload_failures'] ) ) {
			$moderate_now = true;
			$reasons[]    = 'upload_failures';
		}

		$critical_off = ( $mem_ratio < self::CRITICAL_OFF_RATIO )
			&& ( $queue < (int) ( self::QUEUE_CRITICAL * 0.8 ) );

		// Moderate has no separate off-threshold: exit as soon as none of the
		// on-conditions are met. A memory off-threshold would permanently trap
		// scans in moderate when WP baseline memory already exceeds that threshold.
		$moderate_off = ! $moderate_now;

		$optimal_now = ( $mem_ratio < self::OPTIMAL_MEM_RATIO )
			&& ( $queue < self::QUEUE_OPTIMAL )
			&& ! $moderate_now;

		$level = $state['level'] ?? 'normal';
		if ( $level === 'critical' && ! $critical_off ) {
			$level = 'critical';
		} elseif ( $level === 'moderate' && ! $moderate_off && ! $critical_now ) {
			$level = 'moderate';
		} elseif ( $critical_now ) {
			$level = 'critical';
		} elseif ( $moderate_now ) {
			$level = 'moderate';
		} elseif ( $optimal_now ) {
			$level = 'optimal';
		} else {
			$level = 'normal';
		}

		// Elevated level held only by hysteresis (no live trigger this reading).
		if ( in_array( $level, [ 'critical', 'moderate' ], true ) && empty( $reasons ) ) {
			$reasons[] = 'sustained_from_previous_readings';
		}

		// Persist only on change; this runs several times per slice.
		$prev_level = (string) ( $state['level'] ?? '' );
		$prev_ema   = (float) ( $state['mem_ema'] ?? -1.0 );
		if ( $prev_level !== $level || abs( $prev_ema - $mem_metric ) >= 0.25 ) {
			self::save_state( $scan_id, $mem_metric, $level );
		}

		$near_limit = ( $mem_ratio >= self::MODERATE_ON_RATIO );
		$critical   = ( $level === 'critical' );
		$moderate   = ( $level === 'moderate' );
		$optimal    = ( $level === 'optimal' );

		// host_pressure = the host (memory / upload failures) or a sustained prior
		// reading is genuinely constrained, as opposed to a purely local upload
		// backlog. Only host_pressure justifies throttling the upload drain; a
		// backlog alone means the cloud is slow, so the drain must stay wide open.
		$non_backlog_reasons = array_diff( array_unique( $reasons ), [ 'upload_backlog' ] );
		$host_pressure       = ( $critical || $moderate ) && ! empty( $non_backlog_reasons );

		return [
			'memory_cap'    => $mem_cap,
			'near_limit'    => $near_limit,
			'critical'      => $critical,
			'moderate'      => $moderate,
			'optimal'       => $optimal,
			'host_pressure' => $host_pressure,
			'level'         => $level,
			'reasons'       => $reasons,
			'memory_ema'    => round( $mem_metric, 1 ),
		];
	}

	/**
	 * Persist EMA state for hysteresis.
	 *
	 * @param string|null $scan_id Scan ID.
	 * @param float $mem_metric Smoothed memory percent.
	 * @param string $level Pressure level.
	 * @return void
	 */
	private static function save_state( ?string $scan_id, float $mem_metric, string $level ): void {
		if ( empty( $scan_id ) ) {
			return;
		}

		SettingsStore::set_field(
			SettingsStore::COL_STATE,
			'pressure_state_' . $scan_id,
			[
				'mem_ema'    => $mem_metric,
				'level'      => $level,
				'updated_at' => time(),
			]
		);
	}

	/**
	 * Load EMA state for hysteresis.
	 *
	 * @param string|null $scan_id Scan ID.
	 * @return array<string, mixed>
	 */
	private static function load_state( ?string $scan_id ): array {
		if ( empty( $scan_id ) ) {
			return [];
		}

		$state = SettingsStore::get_field( SettingsStore::COL_STATE, 'pressure_state_' . $scan_id, [] );
		return is_array( $state ) ? $state : [];
	}

	/**
	 * Asymmetric exponential moving average for stable pressure signals.
	 *
	 * Uses a higher alpha when pressure rises (fast detection) and a lower
	 * alpha when pressure falls (slow release). This prevents premature exit
	 * from elevated states caused by a single clean reading between spikes.
	 *
	 * @param float $current Current measurement.
	 * @param float $previous Prior EMA.
	 * @return float Smoothed value.
	 */
	private static function ema( float $current, float $previous ): float {
		$alpha = $current > $previous ? self::EMA_ALPHA_UP : self::EMA_ALPHA_DOWN;
		return ( $alpha * $current ) + ( ( 1 - $alpha ) * $previous );
	}

	/**
	 * Return memory pressure cap.
	 *
	 * @return array{memory_percent: int}
	 */
	private static function get_limits(): array {
		return [
			'memory_percent' => self::DEFAULT_MEMORY_LIMIT,
		];
	}

	/**
	 * Clear persisted pressure state for a scan.
	 *
	 * @param string $scan_id Scan ID.
	 * @return void
	 */
	public static function clear_state( string $scan_id ): void {
		if ( $scan_id === '' ) {
			return;
		}

		SettingsStore::delete_field( SettingsStore::COL_STATE, 'pressure_state_' . $scan_id );
	}
}
