<?php
/**
 * Local recommendations for Speed snapshots.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Modules\Speed;

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

/**
 * Converts measured values into concise, actionable advice.
 */
final class RecommendationEngine {

	/**
	 * Autoloaded options above this size slow down every single request.
	 */
	private const AUTOLOAD_WARN_BYTES = 800000;

	/**
	 * Prahy pro pravidla nad PSI a lokální diagnostikou. Vycházejí z oficiálních
	 * hranic Core Web Vitals (LCP 4 s, CLS 0,25, INP 500 ms) a z toho, co je na
	 * WordPressu ještě obhajitelné jako běžný provoz.
	 */
	private const LCP_GOOD_MS         = 2500.0;
	private const LCP_LAB_POOR_MS     = 4000.0;
	private const CLS_GOOD            = 0.1;
	private const INP_GOOD_MS         = 200.0;
	private const ADMIN_SLOW_MS       = 1500.0;
	private const DB_QUERIES_HIGH     = 150;
	private const PROFILE_MAX_AGE     = 86400;
	private const CRON_STUCK_SECONDS  = 86400;
	private const CRON_MANY_OVERDUE   = 5;
	private const CRON_MANY_SECONDS   = 3600;
	private const MEMORY_HEADROOM     = 0.8;
	private const MEMORY_LOW_USE      = 0.5;
	private const OPPORTUNITY_MS      = 500.0;
	private const OPPORTUNITY_BYTES   = 204800.0;

	/**
	 * @param array<string,mixed> $snapshot Local snapshot.
	 * @return array<int,array<string,string>>
	 */
	public function recommendations( array $snapshot ): array {
		$data            = isset( $snapshot['data'] ) && is_array( $snapshot['data'] ) ? $snapshot['data'] : array();
		$recommendations = array();

		$php = $this->ok_field( $data, 'php_ver' );
		if ( null !== $php && is_string( $php['value'] ) ) {
			$eol = $this->php_eol_field( $php );
			if ( 'ok' === $eol['status'] && true === $eol['value'] ) {
				$recommendations[] = array(
					'title' => __( 'Upgrade PHP', 'speedlyst' ),
					'text'  => __( 'PHP developers no longer provide security support for this branch. Some hosts backport fixes, so check with your host; otherwise, switch to a supported version after checking compatibility.', 'speedlyst' ),
				);
			}
		}

		$memory = $this->ok_field( $data, 'memory_limit' );
		if ( null !== $memory ) {
			$bytes = $this->memory_limit_bytes( $memory['value'] );
			$peak  = $this->ok_field( $data, 'memory_peak_mb' );
			$used  = null !== $peak && ( is_int( $peak['value'] ) || is_float( $peak['value'] ) ) && null !== $bytes && $bytes > 0
				? ( (float) $peak['value'] * 1048576 ) / $bytes
				: null;

			// Sám nízký limit web nezpomaluje. Hlásíme ho teprve tehdy, když se
			// spotřeba k limitu reálně blíží, jinak je to falešný poplach.
			if ( null !== $bytes && $bytes >= 0 && $bytes < 128 * 1024 * 1024 && null !== $used && $used >= self::MEMORY_LOW_USE ) {
				$recommendations[] = array(
					'title' => __( 'Increase PHP memory_limit', 'speedlyst' ),
					'text'  => __( 'The limit is below 128 MB and the measurement already approached it. Ask your host to increase it to at least 128 MB, otherwise more demanding operations may result in a white screen.', 'speedlyst' ),
				);
			}
		}

		$opcache = $this->ok_field( $data, 'opcache_enabled' );
		if ( null !== $opcache && false === $opcache['value'] ) {
			$recommendations[] = array(
				'title' => __( 'Enable PHP OPcache', 'speedlyst' ),
				'text'  => __( 'Ask your host to enable OPcache. PHP will then no longer need to recompile the same files on every request.', 'speedlyst' ),
			);
		}

		$autoload = $this->ok_field( $data, 'autoload_bytes' );
		if ( null !== $autoload && ( is_int( $autoload['value'] ) || is_float( $autoload['value'] ) ) && $autoload['value'] > self::AUTOLOAD_WARN_BYTES ) {
			$recommendations[] = array(
				'title' => __( 'Reduce autoload data', 'speedlyst' ),
				'text'  => __( 'Options marked for autoload are loaded on every request and have exceeded the 800 kB warning threshold. Create a backup first, then identify the owners of the largest options on a staging copy; do not overwrite unfamiliar ones without checking.', 'speedlyst' ),
			);
		}

		return $recommendations;
	}

	/**
	 * Recommendations that need the local diagnostics and the last PSI result,
	 * which the snapshot does not carry. Every rule stays a plain threshold over
	 * a measured number; a metric that was not measured produces no advice.
	 *
	 * @param array<string,mixed> $snapshot   Local snapshot.
	 * @param array<string,mixed> $diagnostics Local diagnostics (autoload, cron, request profile).
	 * @param array<string,mixed> $psi        Last PSI result.
	 * @return array<int,array<string,string>>
	 */
	public function extended_recommendations( array $snapshot, array $diagnostics, array $psi ): array {
		$out = array();
		$out = array_merge( $out, $this->memory_headroom_rules( $snapshot ) );
		$out = array_merge( $out, $this->request_rules( $diagnostics ) );
		$out = array_merge( $out, $this->object_cache_rules( $snapshot, $diagnostics ) );
		$out = array_merge( $out, $this->debug_rules( $snapshot, $diagnostics ) );
		$out = array_merge( $out, $this->cron_rules( $diagnostics ) );
		$out = array_merge( $out, $this->psi_rules( $psi ) );

		return $out;
	}

	/**
	 * @param array<string,mixed> $snapshot Local snapshot.
	 * @return array<int,array<string,string>>
	 */
	private function memory_headroom_rules( array $snapshot ): array {
		$data  = isset( $snapshot['data'] ) && is_array( $snapshot['data'] ) ? $snapshot['data'] : array();
		$out   = array();
		$limit = $this->ok_field( $data, 'memory_limit' );
		$peak  = $this->ok_field( $data, 'memory_peak_mb' );

		if ( null === $limit || null === $peak || ! ( is_int( $peak['value'] ) || is_float( $peak['value'] ) ) ) {
			return $out;
		}

		$bytes = $this->memory_limit_bytes( $limit['value'] );

		// -1 znamená bez limitu: paměť nedochází, chybí ale pojistka proti
		// zacyklenému pluginu, který pak sebere paměť celému serveru.
		if ( -1 === $bytes ) {
			$out[] = array(
				'title' => __( 'Set PHP memory_limit', 'speedlyst' ),
				'text'  => __( 'The site is running without a memory limit. A looping plugin can then bring down the entire server, not just one request. Ask your host to set a limit of 256 or 512 MB.', 'speedlyst' ),
			);

			return $out;
		}

		if ( null === $bytes || $bytes <= 0 ) {
			return $out;
		}

		if ( ( (float) $peak['value'] * 1048576 ) / $bytes >= self::MEMORY_HEADROOM ) {
			$out[] = array(
				'title' => __( 'Reduce peak memory usage', 'speedlyst' ),
				'text'  => __( 'The measured request used at least 80 percent of the allowed memory, so little remains. Find the plugin or import causing the peak and ask your host to increase the limit.', 'speedlyst' ),
			);
		}

		return $out;
	}

	/**
	 * A slow admin request is only actionable together with a high query count;
	 * on its own it can be an import or an update, not a defect.
	 *
	 * @param array<string,mixed> $diagnostics Local diagnostics.
	 * @return array<int,array<string,string>>
	 */
	private function request_rules( array $diagnostics ): array {
		$profile = $this->fresh_profile( $diagnostics );
		$out     = array();

		if ( array() === $profile ) {
			return $out;
		}

		$generation = isset( $profile['generation_ms'] ) && ( is_int( $profile['generation_ms'] ) || is_float( $profile['generation_ms'] ) ) ? (float) $profile['generation_ms'] : null;
		$queries    = isset( $profile['db_query_count'] ) && is_int( $profile['db_query_count'] ) ? $profile['db_query_count'] : null;

		if ( null !== $generation && null !== $queries && $generation >= self::ADMIN_SLOW_MS && $queries >= self::DB_QUERIES_HIGH ) {
			$out[] = array(
				'title' => __( 'Find the source of excessive database queries', 'speedlyst' ),
				'text'  => __( 'The last admin page took over 1.5 seconds to generate and required more than 150 queries. Repeat the operation with the Query Monitor plugin and reduce the queries from the largest source.', 'speedlyst' ),
			);
		}

		return $out;
	}

	/**
	 * Persistent object cache is advice only for a site that measurably suffers
	 * from database load. On a small site it adds a moving part for nothing.
	 *
	 * @param array<string,mixed> $snapshot    Local snapshot.
	 * @param array<string,mixed> $diagnostics Local diagnostics.
	 * @return array<int,array<string,string>>
	 */
	private function object_cache_rules( array $snapshot, array $diagnostics ): array {
		$data  = isset( $snapshot['data'] ) && is_array( $snapshot['data'] ) ? $snapshot['data'] : array();
		$cache = $this->ok_field( $data, 'object_cache' );
		$out   = array();

		if ( null === $cache || ! is_array( $cache['value'] ) || ! isset( $cache['value']['external'] ) || false !== $cache['value']['external'] ) {
			return $out;
		}

		$profile = $this->fresh_profile( $diagnostics );
		$queries = isset( $profile['db_query_count'] ) && is_int( $profile['db_query_count'] ) ? $profile['db_query_count'] : null;

		if ( null === $queries || $queries < self::DB_QUERIES_HIGH ) {
			return $out;
		}

		$out[] = array(
			'title' => __( 'Consider persistent object cache', 'speedlyst' ),
			'text'  => __( 'The site has no persistent object cache, yet one admin page made more than 150 database queries. Redis or Memcached will absorb most repeated queries.', 'speedlyst' ),
		);

		return $out;
	}

	/**
	 * WP_DEBUG is desirable on staging, so the advice needs the environment type
	 * WordPress itself reports. Unknown environment means no advice.
	 *
	 * @param array<string,mixed> $snapshot    Local snapshot.
	 * @param array<string,mixed> $diagnostics Local diagnostics.
	 * @return array<int,array<string,string>>
	 */
	private function debug_rules( array $snapshot, array $diagnostics ): array {
		$data        = isset( $snapshot['data'] ) && is_array( $snapshot['data'] ) ? $snapshot['data'] : array();
		$environment = isset( $diagnostics['environment'] ) && is_string( $diagnostics['environment'] ) ? $diagnostics['environment'] : '';
		$debug       = $this->ok_field( $data, 'wp_debug' );
		$out         = array();

		if ( 'production' !== $environment || null === $debug || true !== $debug['value'] ) {
			return $out;
		}

		$out[] = array(
			'title' => __( 'Disable WP_DEBUG', 'speedlyst' ),
			'text'  => __( 'WordPress reports a production environment while debug mode is enabled. Set WP_DEBUG to false so debug information is not logged or displayed to visitors.', 'speedlyst' ),
		);

		return $out;
	}

	/**
	 * @param array<string,mixed> $diagnostics Local diagnostics.
	 * @return array<int,array<string,string>>
	 */
	private function cron_rules( array $diagnostics ): array {
		$cron = isset( $diagnostics['cron'] ) && is_array( $diagnostics['cron'] ) ? $diagnostics['cron'] : array();
		$out  = array();

		if ( 'ok' !== ( isset( $cron['status'] ) ? $cron['status'] : '' ) ) {
			return $out;
		}

		$oldest = isset( $cron['oldest_overdue_seconds'] ) && is_int( $cron['oldest_overdue_seconds'] ) ? $cron['oldest_overdue_seconds'] : null;
		$count  = isset( $cron['overdue_count'] ) && is_int( $cron['overdue_count'] ) ? $cron['overdue_count'] : 0;

		if ( null === $oldest ) {
			return $out;
		}

		// Jedna opožděná úloha je na málo navštěvovaném webu normální. Signálem
		// je až úloha starší dne, nebo pět úloh starších hodiny.
		$stuck = $oldest >= self::CRON_STUCK_SECONDS || ( $count >= self::CRON_MANY_OVERDUE && $oldest >= self::CRON_MANY_SECONDS );

		if ( $stuck ) {
			$out[] = array(
				'title' => __( 'Restore scheduled task execution', 'speedlyst' ),
				'text'  => __( 'The site has scheduled tasks that have not run for a long time. WP-Cron runs when the site is visited, so it stalls on a low-traffic site; run it with server cron.', 'speedlyst' ),
			);
		}

		return $out;
	}

	/**
	 * Field data are a 28 day aggregate from real visitors, so they are measured
	 * against the good threshold. A single lab run only counts when it is bad
	 * beyond doubt and field data are missing.
	 *
	 * @param array<string,mixed> $psi Last PSI result.
	 * @return array<int,array<string,string>>
	 */
	private function psi_rules( array $psi ): array {
		$out = array();

		if ( 'ok' !== ( isset( $psi['status'] ) ? $psi['status'] : '' ) ) {
			return $out;
		}

		$metrics = isset( $psi['metrics'] ) && is_array( $psi['metrics'] ) ? $psi['metrics'] : array();

		$field_lcp = $this->psi_value( $metrics, 'field_lcp_ms' );
		if ( null !== $field_lcp && $field_lcp > self::LCP_GOOD_MS ) {
			$out[] = array(
				'title' => __( 'Speed up main content loading', 'speedlyst' ),
				'text'  => __( 'The main content takes more than 2.5 seconds to appear for real visitors. Start with the item below that has the largest estimated savings, and check the main image and server response.', 'speedlyst' ),
			);
		} else {
			$lab_lcp = $this->psi_value( $metrics, 'lab_lcp_ms' );
			if ( null === $field_lcp && null !== $lab_lcp && $lab_lcp > self::LCP_LAB_POOR_MS ) {
				$out[] = array(
					'title' => __( 'Investigate slow main content loading', 'speedlyst' ),
					'text'  => __( 'The lab measurement exceeded 4 seconds, and real visitor data is unavailable. This is a single run, so verify the result with a second measurement before rebuilding the site.', 'speedlyst' ),
				);
			}
		}

		$cls = $this->psi_value( $metrics, 'field_cls' );
		if ( null !== $cls && $cls > self::CLS_GOOD ) {
			$out[] = array(
				'title' => __( 'Eliminate content shifts during loading', 'speedlyst' ),
				'text'  => __( 'The page layout shifts during loading, causing visitors to click the wrong place. Add dimensions to images and ad slots, and reserve space for elements that load later.', 'speedlyst' ),
			);
		}

		$inp = $this->psi_value( $metrics, 'field_inp_ms' );
		if ( null !== $inp && $inp > self::INP_GOOD_MS ) {
			$out[] = array(
				'title' => __( 'Reduce interaction response time', 'speedlyst' ),
				'text'  => __( 'Interaction response takes over 200 ms for real visitors. Reduce long JavaScript tasks and do not load plugin scripts on pages where they are not needed.', 'speedlyst' ),
			);
		}

		$out = array_merge( $out, $this->dominant_opportunity_rule( $psi ) );

		return $out;
	}

	/**
	 * One clear first step: the audit whose estimated saving dominates the rest.
	 *
	 * @param array<string,mixed> $psi Last PSI result.
	 * @return array<int,array<string,string>>
	 */
	private function dominant_opportunity_rule( array $psi ): array {
		$items = isset( $psi['opportunities'] ) && is_array( $psi['opportunities'] ) ? $psi['opportunities'] : array();
		$best  = 0.0;

		foreach ( $items as $item ) {
			$ms    = isset( $item['savings_ms'] ) && ( is_int( $item['savings_ms'] ) || is_float( $item['savings_ms'] ) ) ? (float) $item['savings_ms'] : 0.0;
			$bytes = isset( $item['savings_bytes'] ) && ( is_int( $item['savings_bytes'] ) || is_float( $item['savings_bytes'] ) ) ? (float) $item['savings_bytes'] : 0.0;
			$score = max( $ms / self::OPPORTUNITY_MS, $bytes / self::OPPORTUNITY_BYTES );
			$best  = max( $best, $score );
		}

		if ( $best < 1.0 ) {
			return array();
		}

		return array(
			array(
				'title' => __( 'Start with the largest PSI opportunity', 'speedlyst' ),
				'text'  => __( 'The top item in the list below promises savings of at least half a second or 200 kB. Address it first and handle the others afterward so you know what worked.', 'speedlyst' ),
			),
		);
	}

	/**
	 * The stored request profile, but only when it is recent enough to describe
	 * the site as it is now.
	 *
	 * @param array<string,mixed> $diagnostics Local diagnostics.
	 * @return array<string,mixed>
	 */
	private function fresh_profile( array $diagnostics ): array {
		$profile = isset( $diagnostics['profile'] ) && is_array( $diagnostics['profile'] ) ? $diagnostics['profile'] : array();
		$stamp   = isset( $profile['captured_ts'] ) && is_int( $profile['captured_ts'] ) ? $profile['captured_ts'] : null;

		if ( null === $stamp || ( time() - $stamp ) > self::PROFILE_MAX_AGE ) {
			return array();
		}

		return $profile;
	}

	/**
	 * @param array<string,mixed> $metrics PSI metrics.
	 * @param string              $key     Metric key.
	 * @return float|null
	 */
	private function psi_value( array $metrics, string $key ): ?float {
		$field = isset( $metrics[ $key ] ) && is_array( $metrics[ $key ] ) ? $metrics[ $key ] : array();

		if ( 'ok' !== ( isset( $field['status'] ) ? $field['status'] : '' ) || ! isset( $field['value'] ) || ! ( is_int( $field['value'] ) || is_float( $field['value'] ) ) ) {
			return null;
		}

		return (float) $field['value'];
	}

	/**
	 * Resolve the official EOL date for known PHP branches.
	 *
	 * Unknown or unmeasured versions remain not_sampled; they are never guessed.
	 *
	 * @param array<string,mixed> $php_field Normalized php_ver field.
	 * @param int|null            $timestamp Optional test timestamp.
	 * @return array<string,mixed>
	 */
	public function php_eol_field( array $php_field, ?int $timestamp = null ): array {
		if ( 'ok' !== ( isset( $php_field['status'] ) ? $php_field['status'] : '' ) || ! isset( $php_field['value'] ) || ! is_string( $php_field['value'] ) ) {
			return array( 'value' => null, 'status' => 'not_sampled' );
		}

		if ( ! preg_match( '/^(7\.4|8\.[0-5])(?:\.|$)/D', $php_field['value'], $matches ) ) {
			return array( 'value' => null, 'status' => 'not_sampled' );
		}

		$eol_dates = array(
			'7.4' => '2022-11-28',
			'8.0' => '2023-11-26',
			'8.1' => '2025-12-31',
			'8.2' => '2026-12-31',
			'8.3' => '2027-12-31',
			'8.4' => '2028-12-31',
			'8.5' => '2029-12-31',
		);
		$now       = null === $timestamp ? time() : $timestamp;
		$eol_at    = strtotime( $eol_dates[ $matches[1] ] . ' 23:59:59 UTC' );

		return array(
			'value'  => false !== $eol_at ? $now > $eol_at : null,
			'status' => false !== $eol_at ? 'ok' : 'not_sampled',
		);
	}

	/**
	 * @param mixed $value PHP shorthand memory limit.
	 * @return int|null
	 */
	public function memory_limit_bytes( $value ): ?int {
		if ( ! is_string( $value ) && ! is_int( $value ) ) {
			return null;
		}

		$value = trim( (string) $value );
		if ( '-1' === $value ) {
			return -1;
		}

		if ( ! preg_match( '/^(\d+)([KMG])?$/iD', $value, $matches ) ) {
			return null;
		}

		$bytes      = (int) $matches[1];
		$multiplier = array( '' => 1, 'K' => 1024, 'M' => 1048576, 'G' => 1073741824 );
		$suffix     = isset( $matches[2] ) ? strtoupper( $matches[2] ) : '';

		if ( $bytes > (int) floor( PHP_INT_MAX / $multiplier[ $suffix ] ) ) {
			return null;
		}

		return $bytes * $multiplier[ $suffix ];
	}

	/**
	 * @param array<string,mixed> $data Snapshot data.
	 * @param string              $key  Field key.
	 * @return array<string,mixed>|null
	 */
	private function ok_field( array $data, string $key ): ?array {
		if ( ! isset( $data[ $key ] ) || ! is_array( $data[ $key ] ) || 'ok' !== ( isset( $data[ $key ]['status'] ) ? $data[ $key ]['status'] : '' ) || ! array_key_exists( 'value', $data[ $key ] ) ) {
			return null;
		}

		return $data[ $key ];
	}
}
