<?php
/**
 * Speed module: local speed collectors and the full report payload.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Modules\Speed;


use MarketingConsole\Modules\Speed\Collectors\CollectorInterface;
use MarketingConsole\Modules\Speed\Collectors\EnvCollector;
use MarketingConsole\Modules\Speed\Collectors\MemoryCollector;
use MarketingConsole\Modules\Speed\Collectors\ObjectCacheCollector;

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

/**
 * Minimal module boundary used by dispatch.
 */
interface SpeedModuleContract {

	/** @return string */
	public function key(): string;

	/** @return string */
	public function version(): string;

	/** @return array<int,CollectorInterface> */
	public function collectors(): array;

	/** @return array<string,mixed> */
	public function collect(): array;
}

/**
 * Owns the allowlisted MVP collectors and normalizes their output.
 */
final class SpeedModule implements SpeedModuleContract {

	private const FIELDS_BY_COLLECTOR = array(
		'env'          => array( 'php_ver', 'wp_ver', 'plugins_active', 'active_theme', 'wp_debug', 'opcache_enabled' ),
		'memory'       => array( 'memory_limit', 'wp_memory_limit', 'wp_max_memory_limit', 'memory_peak_mb' ),
		'object_cache' => array( 'object_cache' ),
	);

	/** @var array<int,CollectorInterface> */
	private array $collectors;

	private string $execution_context;

	/**
	 * @param array<int,CollectorInterface>|null $collectors        Optional collector set.
	 * @param string                             $execution_context Optional context override.
	 */
	public function __construct( ?array $collectors = null, string $execution_context = '' ) {
		$memory_context         = in_array( $execution_context, array( 'wp_cron', 'admin', 'frontend' ), true ) ? $execution_context : MemoryCollector::detect_execution_context();
		$this->execution_context = $memory_context;
		$this->collectors        = null === $collectors
			? array( new EnvCollector(), new MemoryCollector( $memory_context ), new ObjectCacheCollector() )
			: $this->valid_collectors( $collectors );
	}

	/**
	 * Register the module's collector list for composition by the plugin bootstrap.
	 *
	 * @return void
	 */
	public function register(): void {
		add_filter( 'speedlyst_speed_collectors', array( $this, 'filter_collectors' ) );
	}

	/**
	 * @param mixed $collectors Existing value.
	 * @return array<int,CollectorInterface>
	 */
	public function filter_collectors( $collectors ): array {
		return $this->collectors;
	}

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

	/** @return string */
	public function version(): string {
		return '0.1.0';
	}

	/** @return array<int,CollectorInterface> */
	public function collectors(): array {
		return $this->collectors;
	}

	/** @return string */
	public function execution_context(): string {
		return $this->execution_context;
	}

	/**
	 * Run every collector independently so one failure cannot erase the report.
	 *
	 * @return array<string,mixed>
	 */
	public function collect(): array {
		$data   = array();
		$errors = array();

		foreach ( $this->collectors as $collector ) {
			$key      = $collector->key();
			$expected = isset( self::FIELDS_BY_COLLECTOR[ $key ] ) ? self::FIELDS_BY_COLLECTOR[ $key ] : array();

			if ( empty( $expected ) ) {
				continue;
			}

			try {
				$collected = $collector->collect();
			} catch ( \Throwable $exception ) {
				$collected = array();
				foreach ( $expected as $field ) {
					$data[ $field ] = $this->error_field( 'exception' );
					$errors[]       = array( 'code' => 'exception', 'safe_field' => $field );
				}
				continue;
			}

			foreach ( $expected as $field ) {
				if ( ! isset( $collected[ $field ] ) || ! is_array( $collected[ $field ] ) ) {
					$data[ $field ] = $this->error_field( 'unexpected_type' );
					$errors[]       = array( 'code' => 'unexpected_type', 'safe_field' => $field );
					continue;
				}

				$data[ $field ] = $this->normalize_field( $collected[ $field ] );
				if ( 'error' === $data[ $field ]['status'] ) {
					$errors[] = array( 'code' => $data[ $field ]['error_code'], 'safe_field' => $field );
				}
			}
		}

		foreach ( self::FIELDS_BY_COLLECTOR as $fields ) {
			foreach ( $fields as $field ) {
				if ( ! isset( $data[ $field ] ) ) {
					$data[ $field ] = $this->error_field( 'not_available' );
					$errors[]       = array( 'code' => 'not_available', 'safe_field' => $field );
				}
			}
		}

		$partial = false;
		foreach ( $data as $field ) {
			if ( 'ok' !== $field['status'] ) {
				$partial = true;
				break;
			}
		}

		return array(
			'data'             => $data,
			'partial'          => $partial,
			'collector_errors' => array_slice( $errors, 0, 20 ),
		);
	}

	/**
	 * @param array<int,mixed> $collectors Candidate collector set.
	 * @return array<int,CollectorInterface>
	 */
	private function valid_collectors( array $collectors ): array {
		$valid = array();

		foreach ( $collectors as $collector ) {
			if ( $collector instanceof CollectorInterface ) {
				$valid[] = $collector;
			}
		}

		return $valid;
	}

	/**
	 * Keep only recognized field properties and strip every other key.
	 *
	 * @param array<string,mixed> $field Collector field.
	 * @return array<string,mixed>
	 */
	private function normalize_field( array $field ): array {
		$statuses = array( 'ok', 'unsupported', 'disabled', 'error', 'not_sampled' );
		$status   = isset( $field['status'] ) && is_string( $field['status'] ) ? $field['status'] : '';

		if ( ! in_array( $status, $statuses, true ) ) {
			return $this->error_field( 'unexpected_type' );
		}

		$normalized = array(
			'value'  => isset( $field['value'] ) || array_key_exists( 'value', $field ) ? $field['value'] : null,
			'status' => $status,
		);

		if ( isset( $field['source'] ) && is_string( $field['source'] ) ) {
			$source = strtolower( $field['source'] );
			if ( preg_match( '/^[a-z][a-z0-9_]{0,31}$/D', $source ) ) {
				$normalized['source'] = $source;
			}
		}

		if ( isset( $field['observed_at'] ) && is_string( $field['observed_at'] ) && preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/D', $field['observed_at'] ) ) {
			$normalized['observed_at'] = $field['observed_at'];
		}

		if ( 'ok' === $status && null === $normalized['value'] ) {
			return $this->error_field( 'unexpected_type' );
		}

		if ( in_array( $status, array( 'unsupported', 'disabled', 'not_sampled' ), true ) ) {
			$normalized['value'] = null;
		}

		if ( 'error' === $status ) {
			$allowed_codes            = array( 'timeout', 'exception', 'not_available', 'permission', 'unexpected_type' );
			$normalized['value']       = null;
			$normalized['error_code']  = isset( $field['error_code'] ) && in_array( $field['error_code'], $allowed_codes, true ) ? $field['error_code'] : 'unexpected_type';
		}

		return $normalized;
	}

	/**
	 * @param string $code Allowlisted error code.
	 * @return array<string,mixed>
	 */
	private function error_field( string $code ): array {
		return array(
			'value'       => null,
			'status'      => 'error',
			'error_code'  => $code,
			'source'      => 'speed_module',
			'observed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
		);
	}
}


