<?php
/**
 * Read-only memory collector with execution context.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Modules\Speed\Collectors;

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

/**
 * Collects configured limits and the actual peak observed in this request.
 */
final class MemoryCollector implements CollectorInterface {

	private string $execution_context;

	/** @var array<string,mixed> */
	private array $probes;

	/**
	 * @param string              $execution_context Optional explicit context.
	 * @param array<string,mixed> $probes            Deterministic test probes.
	 */
	public function __construct( string $execution_context = '', array $probes = array() ) {
		$this->execution_context = in_array( $execution_context, array( 'wp_cron', 'admin', 'frontend' ), true ) ? $execution_context : self::detect_execution_context();
		$this->probes            = $probes;
	}

	/**
	 * @return string
	 */
	public function key(): string {
		return 'memory';
	}

	/**
	 * The execution context is kept alongside this collector's readings.
	 *
	 * @return string
	 */
	public function execution_context(): string {
		return $this->execution_context;
	}

	/**
	 * @return array<string,array<string,mixed>>
	 */
	public function collect(): array {
		$now = gmdate( 'Y-m-d\TH:i:s\Z' );

		return array(
			'memory_limit'        => $this->limit_field( $this->probe( 'memory_limit', $this->safe_ini( 'memory_limit' ) ), 'php_runtime', $now ),
			'wp_memory_limit'     => $this->limit_field( $this->constant_probe( 'wp_memory_limit', 'WP_MEMORY_LIMIT' ), 'wordpress_config', $now ),
			'wp_max_memory_limit' => $this->limit_field( $this->constant_probe( 'wp_max_memory_limit', 'WP_MAX_MEMORY_LIMIT' ), 'wordpress_config', $now ),
			'memory_peak_mb'      => $this->peak_field( $this->probe( 'memory_peak_bytes', $this->safe_peak() ), $now ),
		);
	}

	/**
	 * @return string
	 */
	public static function detect_execution_context(): string {
		if ( defined( 'DOING_CRON' ) && constant( 'DOING_CRON' ) ) {
			return 'wp_cron';
		}

		if ( function_exists( 'is_admin' ) && is_admin() ) {
			return 'admin';
		}

		return 'frontend';
	}

	/**
	 * @param string $key      Probe key.
	 * @param mixed  $fallback Production value.
	 * @return mixed
	 */
	private function probe( string $key, $fallback ) {
		return array_key_exists( $key, $this->probes ) ? $this->probes[ $key ] : $fallback;
	}

	/**
	 * @param string $key      Probe key.
	 * @param string $constant Constant name.
	 * @return mixed
	 */
	private function constant_probe( string $key, string $constant ) {
		if ( array_key_exists( $key, $this->probes ) ) {
			return $this->probes[ $key ];
		}

		return defined( $constant ) ? constant( $constant ) : null;
	}

	/**
	 * Read a php.ini value without letting a hardened host break the collector.
	 *
	 * On PHP 8 a disabled function is removed from the symbol table and calling
	 * it throws. Isolating the call here means a restricted `ini_get` degrades
	 * only this one field to `unsupported`, never the whole collector.
	 *
	 * @param string $name INI directive.
	 * @return mixed The value, or null when unavailable.
	 */
	private function safe_ini( string $name ) {
		if ( ! function_exists( 'ini_get' ) ) {
			return null;
		}

		try {
			return ini_get( $name );
		} catch ( \Throwable $e ) {
			return null;
		}
	}

	/**
	 * @return mixed Peak bytes, or null when unavailable.
	 */
	private function safe_peak() {
		if ( ! function_exists( 'memory_get_peak_usage' ) ) {
			return null;
		}

		try {
			return memory_get_peak_usage( true );
		} catch ( \Throwable $e ) {
			return null;
		}
	}

	/**
	 * @param mixed  $value  Limit string.
	 * @param string $source Safe source.
	 * @param string $now    Observation time.
	 * @return array<string,mixed>
	 */
	private function limit_field( $value, string $source, string $now ): array {
		if ( null === $value || false === $value || '' === $value ) {
			return array( 'value' => null, 'status' => 'unsupported', 'source' => $source, 'observed_at' => $now );
		}

		if ( ! is_string( $value ) && ! is_int( $value ) ) {
			return array( 'value' => null, 'status' => 'error', 'error_code' => 'unexpected_type', 'source' => $source, 'observed_at' => $now );
		}

		$value = (string) $value;

		return array( 'value' => substr( $value, 0, 128 ), 'status' => 'ok', 'source' => $source, 'observed_at' => $now );
	}

	/**
	 * @param mixed  $bytes Peak bytes.
	 * @param string $now   Observation time.
	 * @return array<string,mixed>
	 */
	private function peak_field( $bytes, string $now ): array {
		if ( null === $bytes ) {
			return array( 'value' => null, 'status' => 'unsupported', 'source' => 'php_runtime', 'observed_at' => $now );
		}

		if ( ! is_int( $bytes ) && ! is_float( $bytes ) ) {
			return array( 'value' => null, 'status' => 'error', 'error_code' => 'unexpected_type', 'source' => 'php_runtime', 'observed_at' => $now );
		}

		if ( $bytes < 0 ) {
			return array( 'value' => null, 'status' => 'error', 'error_code' => 'unexpected_type', 'source' => 'php_runtime', 'observed_at' => $now );
		}

		return array(
			'value'       => round( $bytes / 1048576, 3 ),
			'status'      => 'ok',
			'source'      => 'php_runtime',
			'observed_at' => $now,
		);
	}
}
