<?php
/**
 * Free local performance dashboard.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Admin\Free;

use MarketingConsole\Admin\Icons;
use MarketingConsole\Modules\Speed\LocalHistory;
use MarketingConsole\Modules\Speed\RecommendationEngine;

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

/**
 * Renders local measurements and owns the explicit PSI admin actions.
 */
final class DashboardScreen {

	public const PAGE_SLUG         = 'speedlyst';
	public const OPTION_PSI_RESULT     = 'speedlyst_free_psi_result';
	public const OPTION_PSI_LAST_ERROR = 'speedlyst_free_psi_last_error';
	public const OPTION_PSI_QUOTA      = 'speedlyst_free_psi_quota';

	private const CAPABILITY = 'manage_options';

	/**
	 * Denní strop měření, shodný se stropem naší služby.
	 */
	private const PSI_DAILY_LIMIT = 10;

	private LocalHistory $history;
	private RecommendationEngine $recommendations;
	private PsiClient $psi;
	private \MarketingConsole\Modules\Speed\LocalDiagnostics $diagnostics;
	private PsiHistory $psi_history;
	private ReportExporter $exporter;
	private PsiNonce $psi_nonce;
	private \MarketingConsole\Core\Language $language;

	/**
	 * @param LocalHistory                                        $history         Bounded local history.
	 * @param RecommendationEngine                                $recommendations Local rule engine.
	 * @param PsiClient                                           $psi             Opt-in PSI client.
	 * @param \MarketingConsole\Modules\Speed\LocalDiagnostics    $diagnostics     Free-only local diagnostics.
	 */
	public function __construct( LocalHistory $history, RecommendationEngine $recommendations, PsiClient $psi, \MarketingConsole\Modules\Speed\LocalDiagnostics $diagnostics, ?PsiHistory $psi_history = null, ?\MarketingConsole\Core\Language $language = null ) {
		$this->history         = $history;
		$this->recommendations = $recommendations;
		$this->psi             = $psi;
		$this->diagnostics     = $diagnostics;
		$this->psi_history     = null === $psi_history ? new PsiHistory() : $psi_history;
		$this->exporter        = new ReportExporter( $history, $recommendations, $diagnostics, $this->psi_history );
		$this->psi_nonce       = new PsiNonce();
		// Sdílená instance z bootstrapu; bez ní (testy) si vytvoří vlastní.
		$this->language        = null === $language ? new \MarketingConsole\Core\Language() : $language;
	}

	/**
	 * Register POST-only settings and PSI actions.
	 *
	 * @return void
	 */
	public function register(): void {
		add_action( 'admin_post_speedlyst_free_run_psi', array( $this, 'handle_run_psi' ) );
		add_action( 'admin_post_speedlyst_free_rescan', array( $this, 'handle_rescan' ) );
		add_action( 'admin_post_speedlyst_free_export_md', array( $this, 'handle_export_md' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
		$this->psi_nonce->register();
	}


	/**
	 * Cache-busted URL for the dashboard speed mark.
	 *
	 * @return string
	 */
	private function logo_url(): string {
		$asset       = 'assets/img/logo-signal.svg';
		$asset_path  = dirname( MARKETING_CONSOLE_FILE ) . '/' . $asset;
		$asset_mtime = is_file( $asset_path ) ? filemtime( $asset_path ) : false;
		$version     = false === $asset_mtime ? MARKETING_CONSOLE_VERSION : (string) $asset_mtime;

		return add_query_arg( 'ver', $version, plugin_dir_url( MARKETING_CONSOLE_FILE ) . $asset );
	}


	/**
	 * How many PSI measurements are left for today.
	 *
	 * Our service enforces the same ceiling, so counting locally does not add a
	 * limit; it lets the screen say "none left" before spending a request,
	 * instead of firing one off and translating a remote 429 afterwards.
	 *
	 * @return int
	 */
	private function psi_quota_left(): int {
		$stored = get_option( self::OPTION_PSI_QUOTA, array() );
		$today  = wp_date( 'Y-m-d' );

		if ( ! is_array( $stored ) || ! isset( $stored['date'], $stored['count'] ) || $stored['date'] !== $today || ! is_int( $stored['count'] ) ) {
			return self::PSI_DAILY_LIMIT;
		}

		return max( 0, self::PSI_DAILY_LIMIT - $stored['count'] );
	}

	/**
	 * Count one dispatched measurement against today's ceiling.
	 *
	 * Counted before the request, not after: a run that times out on our side
	 * still reached the service, and a refund path would let a failing
	 * measurement be repeated without any limit at all.
	 *
	 * @return bool
	 */
	private function psi_quota_consume(): bool {
		// Kontrola a inkrement musí být JEDNA operace pod zámkem. Oddělené
		// psi_quota_left() plus zápis by dvěma souběžným POSTům dovolilo
		// přečíst stejný count a odeslat víc měření, než kvóta dovolí.
		$token = \MarketingConsole\Core\OptionLock::acquire( self::OPTION_PSI_QUOTA . '_lock', 10 );

		if ( '' === $token ) {
			return false;
		}

		try {
			$today  = wp_date( 'Y-m-d' );
			$stored = get_option( self::OPTION_PSI_QUOTA, array() );
			$count  = is_array( $stored ) && isset( $stored['date'], $stored['count'] ) && $stored['date'] === $today && is_int( $stored['count'] )
				? $stored['count']
				: 0;

			if ( $count >= self::PSI_DAILY_LIMIT ) {
				return false;
			}

			$next  = array( 'date' => $today, 'count' => $count + 1 );
			$saved = update_option( self::OPTION_PSI_QUOTA, $next, false );

			// Neúspěšný zápis nesmí měření pustit dál, jinak by se limit dal
			// obejít rozbitou databází.
			return $saved || $next === get_option( self::OPTION_PSI_QUOTA, array() );
		} finally {
			\MarketingConsole\Core\OptionLock::release( self::OPTION_PSI_QUOTA . '_lock', $token );
		}
	}

	/**
	 * Measure the local numbers again and replace today's snapshot.
	 *
	 * @return void
	 */
	public function handle_rescan(): void {
		$this->authorize_post( 'speedlyst_free_rescan' );

		$snapshot = $this->history->capture_now();
		$this->redirect( 'ok' === ( isset( $snapshot['storage_status'] ) ? $snapshot['storage_status'] : '' ) ? 'rescan_complete' : 'storage_failed' );
	}

	/**
	 * Loads the FREE dashboard styling only on Speedlyst screens.
	 *
	 * @param string $hook Current wp-admin screen hook.
	 * @return void
	 */
	public function enqueue_assets( string $hook ): void {
		if ( false === strpos( $hook, self::PAGE_SLUG ) ) {
			return;
		}
		$asset_path  = dirname( MARKETING_CONSOLE_FILE ) . '/assets/admin-free-dashboard.css';
		$asset_mtime = is_file( $asset_path ) ? filemtime( $asset_path ) : false;
		$version     = false === $asset_mtime ? MARKETING_CONSOLE_VERSION : (string) $asset_mtime;

		wp_enqueue_style(
			'marketing-console-free-dashboard',
			plugin_dir_url( MARKETING_CONSOLE_FILE ) . 'assets/admin-free-dashboard.css',
			array(),
			$version
		);
	}

	/**
	 * Render the complete FREE dashboard.
	 *
	 * @return void
	 */
	public function render(): void {
		if ( ! current_user_can( self::CAPABILITY ) ) {
			wp_die( esc_html__( 'You are not allowed to manage Speedlyst.', 'speedlyst' ), '', array( 'response' => 403 ) );
		}

		$snapshot       = $this->history->capture_daily();
		$history        = $this->history->history();
		$recommendation = array_merge(
			$this->recommendations->recommendations( $snapshot ),
			$this->recommendations->extended_recommendations(
				$snapshot,
				array(
					'profile'     => $this->diagnostics->request_profile(),
					'cron'        => $this->diagnostics->cron_backlog(),
					'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : '',
				),
				is_array( get_option( self::OPTION_PSI_RESULT, array() ) ) ? get_option( self::OPTION_PSI_RESULT, array() ) : array()
			)
		);
		$psi_result     = get_option( self::OPTION_PSI_RESULT, array() );
		$psi_result     = is_array( $psi_result ) ? $psi_result : array();
		$psi_last_error = get_option( self::OPTION_PSI_LAST_ERROR, array() );
		$psi_last_error = is_array( $psi_last_error ) ? $psi_last_error : array();
		$result_time    = isset( $psi_result['observed_at'] ) && is_string( $psi_result['observed_at'] ) ? strtotime( $psi_result['observed_at'] ) : false;
		$error_time     = isset( $psi_last_error['observed_at'] ) && is_string( $psi_last_error['observed_at'] ) ? strtotime( $psi_last_error['observed_at'] ) : false;

		if ( ! empty( $psi_result ) && ( false === $error_time || ( false !== $result_time && $error_time <= $result_time ) ) ) {
			$psi_last_error = array();
		}

		?>
		<div class="wrap mc-dashboard">
			<div class="mc-dashboard__header">
				<div>
					<div class="mc-dashboard__title">
						<h1><span class="mc-dashboard__mark mc-dashboard__mark--img" aria-hidden="true"><img src="<?php echo esc_url( $this->logo_url() ); ?>" width="32" height="32" alt="" decoding="async"></span><strong><?php echo esc_html__( 'Speedlyst', 'speedlyst' ); ?></strong></h1>
						<span class="mc-updated"><?php echo esc_html( $this->snapshot_stamp( $snapshot ) ); ?></span>
						<?php // Bez tlačítka drží dashboard první dnešní snímek až do půlnoci, i když uživatel mezitím něco změnil. ?>
						<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
							<input type="hidden" name="action" value="speedlyst_free_rescan">
							<?php wp_nonce_field( 'speedlyst_free_rescan' ); ?>
							<button class="mc-rescan" type="submit"><?php echo esc_html__( 'Measure again', 'speedlyst' ); ?></button>
						</form>
					</div>
					<p><?php echo esc_html__( '🔍 Find what is slowing down your WordPress before it becomes a problem. Track memory, object cache, autoload data, cron tasks, and their trends over time. ⚡ PageSpeed Insights adds Google\'s view of your site speed on mobile, and you can download every number in one click as a report ready for AI analysis.', 'speedlyst' ); ?></p>
				</div>
				<div class="mc-dashboard__aside">
					<?php $language = $this->language; ?>
					<form class="mc-lang" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
						<input type="hidden" name="action" value="speedlyst_free_set_locale">
						<?php wp_nonce_field( 'speedlyst_free_set_locale' ); ?>
						<label class="screen-reader-text" for="mc-locale"><?php echo esc_html__( 'Plugin language', 'speedlyst' ); ?></label>
						<span class="mc-lang__field">
							<?php // System používá glóbus, explicitní locale stabilní dvoupísmenný badge. ?>
							<span class="mc-lang__flag" aria-hidden="true"><?php $this->render_language_icon(); ?></span>
							<select id="mc-locale" name="mc_locale" onchange="this.form.submit()">
								<?php foreach ( $language->options() as $value => $label ) : ?>
									<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $language->chosen_locale(), $value ); ?>><?php echo esc_html( $label ); ?></option>
								<?php endforeach; ?>
							</select>
						</span>
						<noscript><button class="button button-secondary" type="submit"><?php echo esc_html__( 'Change', 'speedlyst' ); ?></button></noscript>
					</form>
				</div>
			</div>

			<?php $this->render_notice(); ?>
			<?php $this->render_snapshot( $snapshot ); ?>
			<?php $this->render_performance_trends( $history ); ?>
			<div class="mc-dashboard__split mc-dashboard__split--top mc-dashboard__split--diagnostics">
				<?php $this->render_site_health( $snapshot ); ?>
				<?php $this->render_diagnostics(); ?>
			</div>
			<?php $this->render_history( $history ); ?>
			<?php $this->render_recommendations( $recommendation ); ?>
			<?php $this->render_psi( $psi_result, $psi_last_error ); ?>
			<?php $this->render_report_cta(); ?>
			<?php $this->render_disclaimer(); ?>
			<?php $this->render_brandlinks(); ?>
		</div>
		<?php
	}

	/**
	 * Run the only opt-in external request in the FREE layer.
	 *
	 * @return void
	 */
	public function handle_run_psi(): void {
		$this->authorize_post( 'speedlyst_free_run_psi' );

		$outcome = $this->guarded_measurement();

		$this->redirect(
			array(
				'ok'      => 'psi_complete',
				'quota'   => 'psi_quota_reached',
				'busy'    => 'psi_busy',
				'storage' => 'storage_failed',
			)[ $outcome ] ?? 'storage_failed'
		);
	}

	/**
	 * The whole measurement under one lock: quota, nonce, fetch, clear.
	 *
	 * Serializing the entire sequence with a lock whose TTL outlasts the HTTP
	 * timeout prevents parallel runs from overwriting the single pending nonce
	 * or consuming quota for a nonce that was not stored. The nonce is issued
	 * and the quota consumed only inside the lock, and the quota is consumed
	 * only after the nonce is safely stored, so a failed nonce never costs a run.
	 *
	 * @return string ok|quota|busy|storage
	 */
	private function guarded_measurement(): string {
		$token = \MarketingConsole\Core\OptionLock::acquire( self::OPTION_PSI_QUOTA . '_measure', 90 );

		if ( '' === $token ) {
			return 'busy';
		}

		try {
			if ( $this->psi_quota_left() < 1 ) {
				return 'quota';
			}

			$nonce = $this->psi_nonce->issue();
			if ( '' === $nonce ) {
				return 'storage';
			}

			// Kvóta se zapíše až po bezpečně uloženém nonce, takže neúspěšný
			// nonce nikdy nespálí měření.
			if ( ! $this->psi_quota_consume() ) {
				$this->psi_nonce->clear( $nonce );
				return 'quota';
			}

			$result = $this->psi->fetch_via_service( home_url( '/' ), $nonce );
			$this->psi_nonce->clear( $nonce );

			if ( 'ok' === ( isset( $result['status'] ) ? $result['status'] : '' ) ) {
				update_option( self::OPTION_PSI_RESULT, $result, false );
				$this->psi_history->record( $result );

				return $result === get_option( self::OPTION_PSI_RESULT, array() ) ? 'ok' : 'storage';
			}

			update_option( self::OPTION_PSI_LAST_ERROR, $result, false );

			return $result === get_option( self::OPTION_PSI_LAST_ERROR, array() ) ? 'ok' : 'storage';
		} finally {
			\MarketingConsole\Core\OptionLock::release( self::OPTION_PSI_QUOTA . '_measure', $token );
		}
	}

	/**
	 * Send every measured number as one Markdown file, ready to hand to an AI.
	 *
	 * @return void
	 */
	public function handle_export_md(): void {
		$this->authorize_post( 'speedlyst_free_export_md' );

		$psi_result = get_option( self::OPTION_PSI_RESULT, array() );
		$markdown   = $this->exporter->markdown( is_array( $psi_result ) ? $psi_result : array() );

		nocache_headers();
		header( 'Content-Type: text/markdown; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . $this->exporter->filename() . '"' );
		header( 'Content-Length: ' . strlen( $markdown ) );
		echo $markdown; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Plain text file download, not HTML.
		exit;
	}

	/**
	 * Free-only local diagnostics that remain on this site: autoload bloat,
	 * cron backlog and the last admin request profile.
	 *
	 * @return void
	 */
	private function render_diagnostics(): void {
		$auto    = $this->diagnostics->autoloaded_options();
		$cron    = $this->diagnostics->cron_backlog();
		$profile = $this->diagnostics->request_profile();
		?>
		<section class="mc-panel mc-panel--diagnostics">
			<h2><?php echo Icons::svg( 'chart-line' ); ?><?php echo esc_html__( 'Advanced local diagnostics', 'speedlyst' ); ?></h2>
			<table class="widefat striped mc-table mc-table--kv">
			<tbody>
				<?php $this->render_row( __( 'Autoload options (count)', 'speedlyst' ), $this->diag( $auto, 'count', 'int' ) ); ?>
				<?php $this->render_row( __( 'Autoload options (size)', 'speedlyst' ), $this->diag( $auto, 'bytes', 'kb' ) ); ?>
				<?php $this->render_row( __( 'Cron: delayed tasks', 'speedlyst' ), $this->diag( $cron, 'overdue_count', 'int' ), $this->is_overdue( $cron, 'overdue_count' ), __( 'WP-Cron runs only when the site is visited. A few minutes of delay is normal; hours mean tasks such as backups or email delivery are waiting.', 'speedlyst' ) ); ?>
				<?php $this->render_row( __( 'Cron: oldest delay', 'speedlyst' ), $this->diag( $cron, 'oldest_overdue_seconds', 'sec' ), $this->is_overdue( $cron, 'oldest_overdue_seconds' ), __( 'How long the oldest unrun task has been waiting. Minutes are fine; days mean WP-Cron is effectively not running on this site.', 'speedlyst' ) ); ?>
				<?php $this->render_row( __( 'Last admin request: generation time', 'speedlyst' ), $this->diag( $profile, 'generation_ms', 'ms' ) ); ?>
				<?php $this->render_row( __( 'Last admin request: DB queries', 'speedlyst' ), $this->diag( $profile, 'db_query_count', 'int' ) ); ?>
				<?php $this->render_row( __( 'Last admin request: peak memory', 'speedlyst' ), $this->diag( $profile, 'peak_memory_mb', 'mb' ) ); ?>
			</tbody>
		</table>
		<?php $this->render_autoload_offenders(); ?>
		</section>
		<?php
	}

	/**
	 * The largest autoloaded options, so the number above has a culprit.
	 *
	 * Read live for this admin request, never stored and never sent anywhere.
	 *
	 * @return void
	 */
	private function render_autoload_offenders(): void {
		$top = $this->diagnostics->autoloaded_options_top( 8 );

		if ( 'ok' !== ( isset( $top['status'] ) ? $top['status'] : '' ) || empty( $top['rows'] ) ) {
			return;
		}
		?>
		<h3><?php echo Icons::svg( 'list-view' ); ?><?php echo esc_html__( 'Largest autoload options', 'speedlyst' ); ?></h3>
		<p class="description"><?php echo esc_html__( 'They load on every request. Names are read only for this list and are not stored or sent anywhere.', 'speedlyst' ); ?></p>
		<table class="widefat striped mc-table mc-autoload-table">
			<tbody>
				<?php foreach ( $top['rows'] as $row ) : ?>
					<tr>
						<th scope="row"><?php echo esc_html( (string) $row['name'] ); ?></th>
						<td><?php echo esc_html( $this->format_bytes( (float) $row['bytes'] ) ); ?></td>
					</tr>
				<?php endforeach; ?>
			</tbody>
		</table>
		<?php
	}

	/**
	 * @param array<string,mixed> $arr  Diagnostics array.
	 * @param string              $key  Value key.
	 * @param string              $type Format type.
	 * @return string
	 */
	private function diag( array $arr, string $key, string $type ): string {
		if ( isset( $arr['status'] ) && 'ok' !== $arr['status'] ) {
			return __( 'not measured', 'speedlyst' );
		}

		if ( ! isset( $arr[ $key ] ) || null === $arr[ $key ] || ! ( is_int( $arr[ $key ] ) || is_float( $arr[ $key ] ) ) ) {
			return __( 'not measured', 'speedlyst' );
		}

		$value = $arr[ $key ];
		switch ( $type ) {
			case 'kb':
				return number_format_i18n( round( $value / 1024 ) ) . ' kB';
			case 'ms':
				return number_format_i18n( round( $value ) ) . ' ms';
			case 'mb':
				return number_format_i18n( $value, 1 ) . ' MB';
			case 'sec':
				return $this->human_seconds( (int) $value );
			case 'int':
			default:
				return number_format_i18n( $value );
		}
	}

	/**
	 * @param int $seconds Duration.
	 * @return string
	 */
	private function human_seconds( int $seconds ): string {
		if ( $seconds < 60 ) {
			return number_format_i18n( $seconds ) . ' s';
		}
		if ( $seconds < 3600 ) {
			return number_format_i18n( intdiv( $seconds, 60 ) ) . ' min';
		}
		if ( $seconds < 86400 ) {
			return number_format_i18n( intdiv( $seconds, 3600 ) ) . ' h';
		}

		return number_format_i18n( intdiv( $seconds, 86400 ) ) . ' d';
	}

	/**
	 * @param array<string,mixed> $snapshot Current local snapshot.
	 * @return void
	 */
	private function render_snapshot( array $snapshot ): void {
		$data = isset( $snapshot['data'] ) && is_array( $snapshot['data'] ) ? $snapshot['data'] : array();
		$cron = $this->diagnostics->cron_backlog();
		$cards = array(
			array( __( 'PHP memory_limit', 'speedlyst' ), $this->format_field( $this->field( $data, 'memory_limit' ), 'memory_limit' ), 'database', $this->card_state( 'memory_limit', $data, $cron ) ),
			array( __( 'Peak memory', 'speedlyst' ), $this->format_field( $this->field( $data, 'memory_peak_mb' ), 'megabytes' ), 'chart-line', $this->card_state( 'memory_peak', $data, $cron ) ),
			array( __( 'PHP version', 'speedlyst' ), $this->format_field( $this->field( $data, 'php_ver' ), 'plain' ), 'editor-code', $this->card_state( 'php', $data, $cron ) ),
			array( __( 'Autoload data', 'speedlyst' ), $this->format_field( $this->field( $data, 'autoload_bytes' ), 'bytes' ), 'database-view', $this->card_state( 'autoload', $data, $cron ) ),
			array( __( 'OPcache', 'speedlyst' ), $this->format_field( $this->field( $data, 'opcache_enabled' ), 'enabled' ), 'performance', $this->card_state( 'opcache', $data, $cron ) ),
			array( __( 'Cron delay', 'speedlyst' ), $this->diag( $cron, 'oldest_overdue_seconds', 'sec' ), 'clock', $this->card_state( 'cron', $data, $cron ) ),
		);
		?>
		<div class="mc-metric-grid" aria-label="<?php echo esc_attr__( 'Site status summary', 'speedlyst' ); ?>">
			<?php foreach ( $cards as $card ) : ?>
				<div class="mc-metric-card<?php echo esc_attr( $this->value_class( $card[1] ) ); ?>">
					<span class="mc-metric-card__icon"><?php echo Icons::svg( (string) $card[2] ); ?></span>
					<span>
						<small><?php echo esc_html( $card[0] ); ?></small>
						<strong><?php echo esc_html( $card[1] ); ?></strong>
						<?php if ( '' !== $card[3]['label'] ) : ?>
							<em class="mc-state mc-state--<?php echo esc_attr( $card[3]['tone'] ); ?><?php echo '' !== $card[3]['reason'] ? ' mc-hint' : ''; ?>"<?php echo '' !== $card[3]['reason'] ? ' tabindex="0" role="button" aria-label="' . esc_attr( $card[3]['reason'] ) . '"' : ''; ?>>
								<i aria-hidden="true"></i><?php echo esc_html( $card[3]['label'] ); ?>
								<?php if ( '' !== $card[3]['reason'] ) : ?>
									<span class="mc-hint__bubble"><?php echo esc_html( $card[3]['reason'] ); ?></span>
								<?php endif; ?>
							</em>
						<?php endif; ?>
					</span>
				</div>
			<?php endforeach; ?>
		</div>
		<?php if ( isset( $snapshot['storage_status'] ) && 'error' === $snapshot['storage_status'] ) : ?>
			<div class="notice notice-error inline"><p><?php echo esc_html__( 'Current values were measured, but the daily history could not be saved.', 'speedlyst' ); ?></p></div>
		<?php endif; ?>
		<?php
	}

	/**
	 * Render the detailed local site-health panel.
	 *
	 * @param array<string,mixed> $snapshot Current local snapshot.
	 * @return void
	 */
	private function render_site_health( array $snapshot ): void {
		$data = isset( $snapshot['data'] ) && is_array( $snapshot['data'] ) ? $snapshot['data'] : array();
		$eol  = $this->recommendations->php_eol_field( $this->field( $data, 'php_ver' ) );
		?>
		<section id="mc-site-health" class="mc-panel mc-panel--health">
			<h2><?php echo Icons::svg( 'chart-pie' ); ?><?php echo esc_html__( 'Site status', 'speedlyst' ); ?></h2>
			<table class="widefat striped mc-table mc-table--kv">
			<tbody>
				<?php $this->render_row( __( 'PHP memory_limit', 'speedlyst' ), $this->format_field( $this->field( $data, 'memory_limit' ), 'memory_limit' ) ); ?>
				<?php $this->render_row( __( 'Peak memory in this measurement', 'speedlyst' ), $this->format_field( $this->field( $data, 'memory_peak_mb' ), 'megabytes' ) ); ?>
				<?php $this->render_row( __( 'Persistent object cache', 'speedlyst' ), $this->format_field( $this->field( $data, 'object_cache' ), 'object_cache' ), $this->cache_inactive( $data ), __( 'The site has no persistent object cache, so the same database queries repeat on every load. An "unknown" backend only means no cache is running, not that it is broken.', 'speedlyst' ) ); ?>
				<?php $this->render_row( __( 'PHP version', 'speedlyst' ), $this->format_field( $this->field( $data, 'php_ver' ), 'plain' ) ); ?>
				<?php $this->render_row( __( 'PHP past end of life (EOL)', 'speedlyst' ), $this->format_field( $eol, 'boolean' ), $this->is_true( $eol ), __( 'PHP developers no longer release security fixes for this branch. Some hosts backport them, so check with your host. Newer PHP is often noticeably faster too.', 'speedlyst' ) ); ?>
				<?php $this->render_row( __( 'OPcache', 'speedlyst' ), $this->format_field( $this->field( $data, 'opcache_enabled' ), 'enabled' ), $this->is_false( $this->field( $data, 'opcache_enabled' ) ), __( 'Without OPcache, PHP must recompile the same files on every request. Your host must enable it, and it is often one of the least expensive performance improvements.', 'speedlyst' ) ); ?>
				<?php $this->render_row( __( 'WP_DEBUG', 'speedlyst' ), $this->format_field( $this->field( $data, 'wp_debug' ), 'enabled' ), $this->is_true( $this->field( $data, 'wp_debug' ) ), __( 'Debug mode logs errors and may also display them to visitors. Disable it in production; it is fine on a staging copy.', 'speedlyst' ) ); ?>
				<?php $this->render_row( __( 'Active plugins', 'speedlyst' ), $this->format_field( $this->field( $data, 'plugins_active' ), 'integer' ) ); ?>
				<?php $this->render_row( __( 'Active theme', 'speedlyst' ), $this->format_field( $this->field( $data, 'active_theme' ), 'plain' ) ); ?>
			</tbody>
		</table>
		</section>
		<?php
	}

	/**
	 * Render three compact performance trends from the existing local history.
	 * Every metric uses its own scale so unrelated units are never compared.
	 *
	 * @param array<int,array<string,mixed>> $history Bounded local history.
	 * @return void
	 */
	private function render_performance_trends( array $history ): void {
		$recent  = array_slice( $history, -14 );
		$metrics = array(
			array(
				'key'   => 'memory_peak_mb',
				'label' => __( 'Peak memory', 'speedlyst' ),
				'unit'  => __( 'MB (lower is better)', 'speedlyst' ),
				'type'  => 'megabytes',
				'color' => 'blue',
			),
			array(
				'key'   => 'memory_utilization',
				'label' => __( 'memory_limit usage', 'speedlyst' ),
				'unit'  => __( '% (lower is better)', 'speedlyst' ),
				'type'  => 'percentage',
				'color' => 'violet',
			),
			array(
				'key'   => 'autoload_bytes',
				'label' => __( 'Autoload data', 'speedlyst' ),
				'unit'  => __( 'size (lower is better)', 'speedlyst' ),
				'type'  => 'bytes',
				'color' => 'teal',
			),
		);
		?>
		<section class="mc-panel mc-panel--performance">
			<div class="mc-panel__heading mc-panel__heading--performance">
				<div>
					<h2><?php echo Icons::svg( 'performance' ); ?><?php echo esc_html__( 'Performance trend', 'speedlyst' ); ?></h2>
					<p><?php echo esc_html__( 'The last 14 available daily snapshots. Each metric has its own scale.', 'speedlyst' ); ?></p>
				</div>
				<span class="mc-performance-period"><?php echo esc_html( $this->trend_heading( count( $recent ) ) ); ?></span>
			</div>
			<?php if ( empty( $recent ) ) : ?>
				<p class="mc-empty-state"><?php echo esc_html__( 'There is not enough measurement history for the chart yet.', 'speedlyst' ); ?></p>
			</section>
				<?php return; ?>
			<?php endif; ?>
			<div class="mc-performance-grid">
				<?php $day_labels = $this->performance_day_labels( $recent ); ?>
				<?php foreach ( $metrics as $metric ) : ?>
					<?php
					$values   = $this->performance_values( $recent, $metric['key'], $metric['type'] );
					$latest   = $this->latest_measured_value( $values );
					$headline = $this->performance_headline_delta( $values, $day_labels, (string) $metric['type'] );
					$footer   = $this->performance_footer_delta( $values, (string) $metric['type'] );
					?>
					<article class="mc-performance-card mc-performance-card--<?php echo esc_attr( $metric['color'] ); ?>">
						<div class="mc-performance-card__header">
							<span class="mc-performance-card__label"><?php echo esc_html( $metric['label'] ); ?></span>
							<strong class="<?php echo esc_attr( null === $latest ? 'mc-is-unmeasured' : '' ); ?>">
								<?php echo esc_html( $this->format_performance_value( $latest, $metric['type'] ) ); ?>
							</strong>
							<small class="mc-performance-card__unit"><?php echo esc_html( (string) $metric['unit'] ); ?></small>
							<span class="mc-performance-card__delta mc-delta--<?php echo esc_attr( $headline['tone'] ); ?>">
								<?php if ( 'flat' === $headline['tone'] ) : ?>
									<i aria-hidden="true"></i>
								<?php elseif ( 'none' !== $headline['direction'] ) : ?>
									<span class="mc-delta__arrow" aria-hidden="true"><?php echo 'down' === $headline['direction'] ? '&#8595;' : '&#8593;'; ?></span>
								<?php endif; ?>
								<?php echo esc_html( $headline['text'] ); ?>
							</span>
						</div>
						<?php $this->render_performance_chart( $values, $day_labels, (string) $metric['type'], (string) $metric['label'] ); ?>
						<p class="mc-performance-card__footer mc-delta--<?php echo esc_attr( $footer['tone'] ); ?>">
							<i aria-hidden="true"></i><?php echo esc_html( $footer['text'] ); ?>
						</p>
					</article>
				<?php endforeach; ?>
			</div>
		</section>
		<?php
	}

	/**
	 * @param array<int,array<string,mixed>> $history Visible history.
	 * @param string                         $key     Metric key.
	 * @param string                         $type    Metric type.
	 * @return array<int,float|null>
	 */
	private function performance_values( array $history, string $key, string $type ): array {
		$values = array();

		foreach ( $history as $entry ) {
			$data = isset( $entry['data'] ) && is_array( $entry['data'] ) ? $entry['data'] : array();

			if ( 'memory_utilization' === $key ) {
				$peak        = $this->trend_numeric_value( $this->field( $data, 'memory_peak_mb' ), 'megabytes' );
				$limit_field = $this->field( $data, 'memory_limit' );
				$limit_bytes = 'ok' === ( isset( $limit_field['status'] ) ? $limit_field['status'] : '' ) && isset( $limit_field['value'] )
					? $this->recommendations->memory_limit_bytes( (string) $limit_field['value'] )
					: -1;
				$limit_mb    = $limit_bytes > 0 ? $limit_bytes / 1048576 : 0;
				$values[]    = null !== $peak && $limit_mb > 0 ? max( 0.0, $peak / $limit_mb * 100 ) : null;
				continue;
			}

			$values[] = $this->trend_numeric_value( $this->field( $data, $key ), $type );
		}

		return $values;
	}

	/**
	 * @param array<int,float|null> $values Numeric trend values.
	 * @return float|null
	 */
	private function latest_measured_value( array $values ): ?float {
		for ( $index = count( $values ) - 1; $index >= 0; $index-- ) {
			if ( null !== $values[ $index ] ) {
				return $values[ $index ];
			}
		}

		return null;
	}

	/**
	 * @param float|null $value Measured value.
	 * @param string     $type  Metric type.
	 * @return string
	 */
	private function format_performance_value( ?float $value, string $type ): string {
		if ( null === $value ) {
			return __( 'not measured', 'speedlyst' );
		}

		if ( 'percentage' === $type ) {
			return number_format_i18n( $value, 1 ) . ' %';
		}

		if ( 'megabytes' === $type ) {
			return number_format_i18n( $value, 1 ) . ' MB';
		}

		if ( 'bytes' === $type ) {
			return $this->format_bytes( $value );
		}

		if ( 'milliseconds' === $type ) {
			return number_format_i18n( round( $value ) ) . ' ms';
		}

		if ( 'decimal' === $type ) {
			return number_format_i18n( $value, 3 );
		}

		return number_format_i18n( round( $value ) );
	}

	/**
	 * Human byte size: kB up to one megabyte, MB above it.
	 *
	 * @param float $bytes Measured size.
	 * @return string
	 */
	private function format_bytes( float $bytes ): string {
		if ( $bytes < 1048576 ) {
			return number_format_i18n( round( $bytes / 1024 ) ) . ' kB';
		}

		return number_format_i18n( $bytes / 1048576, 1 ) . ' MB';
	}

	/**
	 * Weekday labels (Po, Út, ...) for the visible snapshots.
	 *
	 * @param array<int,array<string,mixed>> $history Visible history entries.
	 * @return array<int,string>
	 */
	private function performance_day_labels( array $history ): array {
		$days   = array(
			__( 'Sun', 'speedlyst' ),
			__( 'Mon', 'speedlyst' ),
			__( 'Tue', 'speedlyst' ),
			__( 'Wed', 'speedlyst' ),
			__( 'Thu', 'speedlyst' ),
			__( 'Fri', 'speedlyst' ),
			__( 'Sat', 'speedlyst' ),
		);
		$labels = array();

		foreach ( $history as $entry ) {
			$date      = isset( $entry['date'] ) && is_string( $entry['date'] ) ? $entry['date'] : '';
			$timestamp = preg_match( '/^\d{4}-\d{2}-\d{2}$/D', $date ) ? strtotime( $date . ' 12:00:00 UTC' ) : false;
			$labels[]  = false === $timestamp ? '' : $days[ (int) gmdate( 'w', $timestamp ) ];
		}

		return $labels;
	}

	/**
	 * Change between the first and the last measured point of the window.
	 *
	 * @param array<int,float|null> $values     Numeric trend values.
	 * @param array<int,string>     $day_labels Weekday labels.
	 * @param string                $type       Metric type.
	 * @return array<string,string> text and direction.
	 */
	private function performance_headline_delta( array $values, array $day_labels, string $type, bool $lower_is_better = true ): array {
		$first_index = null;
		$last_index  = null;

		foreach ( $values as $index => $value ) {
			if ( null === $value ) {
				continue;
			}
			if ( null === $first_index ) {
				$first_index = $index;
			}
			$last_index = $index;
		}

		if ( null === $first_index || $first_index === $last_index ) {
			return array( 'text' => __( 'no comparison yet', 'speedlyst' ), 'direction' => 'none', 'tone' => 'none' );
		}

		$first = (float) $values[ $first_index ];
		$last  = (float) $values[ $last_index ];
		$since = isset( $day_labels[ $first_index ] ) && '' !== $day_labels[ $first_index ] ? $day_labels[ $first_index ] : __( 'start', 'speedlyst' );

		if ( $this->same_displayed_value( $first, $last, $type ) ) {
			return array( 'text' => __( 'Stable', 'speedlyst' ), 'direction' => 'flat', 'tone' => 'flat' );
		}

		if ( 'percentage' === $type ) {
			$change = number_format_i18n( abs( $last - $first ), 1 ) . ' ' . __( 'pp', 'speedlyst' );
		} elseif ( 0.0 === $first ) {
			$change = $this->format_performance_value( abs( $last - $first ), $type );
		} else {
			$change = number_format_i18n( round( abs( $last - $first ) / abs( $first ) * 100 ) ) . ' %';
		}

		$lower = $last < $first;

		return array(
			/* translators: 1: change, 2: weekday of the first measurement. */
			'text'      => sprintf( __( '%1$s vs. %2$s', 'speedlyst' ), $change, $since ),
			'direction' => $lower ? 'down' : 'up',
			'tone'      => $lower === $lower_is_better ? 'good' : 'bad',
		);
	}

	/**
	 * Change between the last two measured points.
	 *
	 * @param array<int,float|null> $values Numeric trend values.
	 * @param string                $type   Metric type.
	 * @return array<string,string> text and direction.
	 */
	private function performance_footer_delta( array $values, string $type, bool $lower_is_better = true ): array {
		$measured = array_values(
			array_filter(
				$values,
				static function ( $value ) {
					return null !== $value;
				}
			)
		);
		$count    = count( $measured );

		if ( $count < 2 ) {
			return array( 'text' => __( 'Only one measurement so far.', 'speedlyst' ), 'direction' => 'none', 'tone' => 'none' );
		}

		$previous = (float) $measured[ $count - 2 ];
		$last     = (float) $measured[ $count - 1 ];

		if ( $this->same_displayed_value( $previous, $last, $type ) ) {
			return array( 'text' => __( 'No change since the previous measurement', 'speedlyst' ), 'direction' => 'flat', 'tone' => 'flat' );
		}

		$difference = 'percentage' === $type
			? number_format_i18n( abs( $last - $previous ), 1 ) . ' ' . __( 'pp', 'speedlyst' )
			: $this->format_performance_value( abs( $last - $previous ), $type );

		$lower = $last < $previous;

		return array(
			'text'      => $lower
				/* translators: %s: measured difference. */
				? sprintf( __( '%s lower than the previous measurement', 'speedlyst' ), $difference )
				/* translators: %s: measured difference. */
				: sprintf( __( '%s higher than the previous measurement', 'speedlyst' ), $difference ),
			'direction' => $lower ? 'down' : 'up',
			'tone'      => $lower === $lower_is_better ? 'good' : 'bad',
		);
	}

	/**
	 * True when two values render identically, so no fake change is reported.
	 *
	 * @param float  $left  First value.
	 * @param float  $right Second value.
	 * @param string $type  Metric type.
	 * @return bool
	 */
	private function same_displayed_value( float $left, float $right, string $type ): bool {
		return $this->format_performance_value( $left, $type ) === $this->format_performance_value( $right, $type );
	}

	/**
	 * Evenly spaced x-axis label positions, always including the first and last.
	 *
	 * @param int $count Number of visible points.
	 * @return array<int,int>
	 */
	private function axis_label_indexes( int $count ): array {
		if ( $count < 2 ) {
			return array( 0 );
		}

		$wanted  = min( 6, $count );
		$indexes = array();

		for ( $slot = 0; $slot < $wanted; $slot++ ) {
			$indexes[] = (int) round( $slot * ( $count - 1 ) / ( $wanted - 1 ) );
		}

		return array_values( array_unique( $indexes ) );
	}

	/**
	 * Axis scale with round gridline labels in a single unit.
	 *
	 * @param float  $maximum Highest measured value.
	 * @param string $type    Metric type.
	 * @return array<string,mixed> max, divisor, suffix and decimals.
	 */
	private function performance_axis( float $maximum, string $type ): array {
		if ( 'percentage' === $type ) {
			return array( 'max' => 100.0, 'divisor' => 1.0, 'suffix' => '%', 'decimals' => 0 );
		}

		if ( 'score' === $type ) {
			return array( 'max' => 100.0, 'divisor' => 1.0, 'suffix' => '', 'decimals' => 0 );
		}

		$divisor = 1.0;
		$suffix  = 'MB';

		if ( 'milliseconds' === $type ) {
			$suffix = 'ms';
		}

		if ( 'decimal' === $type ) {
			$suffix = '';
		}

		if ( 'bytes' === $type ) {
			$divisor = $maximum >= 1048576 ? 1048576.0 : 1024.0;
			$suffix  = $maximum >= 1048576 ? 'MB' : 'kB';
		}

		$scaled   = $maximum / $divisor;
		$rounded  = $this->nice_ceiling( $scaled );
		$decimals = ( $rounded / 4 ) < 1 ? ( 'decimal' === $type ? 2 : 1 ) : 0;

		return array(
			'max'      => $rounded * $divisor,
			'divisor'  => $divisor,
			'suffix'   => $suffix,
			'decimals' => $decimals,
		);
	}

	/**
	 * Smallest round number at or above the value, divisible into four ticks.
	 *
	 * @param float $value Scaled maximum.
	 * @return float
	 */
	private function nice_ceiling( float $value ): float {
		if ( $value <= 0 ) {
			return 4.0;
		}

		$target = $value * 1.15;
		$step   = pow( 10, floor( log10( $target / 4 ) ) );

		foreach ( array( 1, 2, 2.5, 5, 10 ) as $factor ) {
			if ( $step * $factor * 4 >= $target ) {
				return $step * $factor * 4;
			}
		}

		return ceil( $target );
	}

	/**
	 * Render the metric trend with its own axes; missing days stay gaps.
	 *
	 * @param array<int,float|null> $values     Numeric trend values.
	 * @param array<int,string>     $day_labels Weekday labels.
	 * @param string                $type       Metric type.
	 * @param string                $label      Accessible metric label.
	 * @return void
	 */
	private function render_performance_chart( array $values, array $day_labels, string $type, string $label ): void {
		$measured = array_values(
			array_filter(
				$values,
				static function ( $value ) {
					return null !== $value;
				}
			)
		);

		if ( count( $measured ) < 2 ) {
			?>
			<div class="mc-performance-chart mc-performance-chart--empty">
				<span><?php echo esc_html__( 'The trend will be available after the second measurement.', 'speedlyst' ); ?></span>
			</div>
			<?php
			return;
		}

		$axis      = $this->performance_axis( (float) max( $measured ), $type );
		$axis_max  = (float) $axis['max'];
		$left      = 52.0;
		$right     = 348.0;
		$top       = 20.0;
		$bottom    = 152.0;
		$last      = max( 1, count( $values ) - 1 );
		$tick_days = $this->axis_label_indexes( count( $values ) );
		$segments  = array();
		$segment   = array();
		$points    = array();

		foreach ( $values as $index => $value ) {
			if ( null === $value ) {
				if ( count( $segment ) > 1 ) {
					$segments[] = $segment;
				}
				$segment = array();
				continue;
			}

			$x                = $left + ( ( $right - $left ) * $index / $last );
			$ratio            = $axis_max > 0 ? min( 1.0, max( 0.0, (float) $value / $axis_max ) ) : 0.0;
			$y                = $bottom - ( ( $bottom - $top ) * $ratio );
			$segment[]        = array( $x, $y );
			$points[ $index ] = array( $x, $y );
		}

		if ( count( $segment ) > 1 ) {
			$segments[] = $segment;
		}

		/* translators: %s: name of the metric shown in the chart. */
		$chart_aria = sprintf( __( '%s metric trend', 'speedlyst' ), $label );
		?>
		<svg class="mc-performance-chart" viewBox="0 0 360 190" role="img" aria-label="<?php echo esc_attr( $chart_aria ); ?>" focusable="false">
			<?php for ( $tick = 0; $tick <= 4; $tick++ ) : ?>
				<?php $tick_y = $bottom - ( ( $bottom - $top ) * $tick / 4 ); ?>
				<line class="<?php echo esc_attr( 0 === $tick ? 'mc-performance-chart__baseline' : 'mc-performance-chart__guide' ); ?>" x1="<?php echo esc_attr( (string) $left ); ?>" y1="<?php echo esc_attr( number_format( $tick_y, 1, '.', '' ) ); ?>" x2="<?php echo esc_attr( (string) $right ); ?>" y2="<?php echo esc_attr( number_format( $tick_y, 1, '.', '' ) ); ?>"></line>
				<text class="mc-performance-chart__axis" x="<?php echo esc_attr( (string) ( $left - 10 ) ); ?>" y="<?php echo esc_attr( number_format( $tick_y + 4, 1, '.', '' ) ); ?>" text-anchor="end"><?php echo esc_html( number_format_i18n( $axis_max * $tick / 4 / (float) $axis['divisor'], (int) $axis['decimals'] ) . ' ' . $axis['suffix'] ); ?></text>
			<?php endfor; ?>
			<?php foreach ( $segments as $line ) : ?>
				<?php
				$area = 'M ' . number_format( $line[0][0], 1, '.', '' ) . ',' . number_format( $bottom, 1, '.', '' );
				foreach ( $line as $point ) {
					$area .= ' L ' . number_format( $point[0], 1, '.', '' ) . ',' . number_format( $point[1], 1, '.', '' );
				}
				$area .= ' L ' . number_format( $line[ count( $line ) - 1 ][0], 1, '.', '' ) . ',' . number_format( $bottom, 1, '.', '' ) . ' Z';
				$stroke = array();
				foreach ( $line as $point ) {
					$stroke[] = number_format( $point[0], 1, '.', '' ) . ',' . number_format( $point[1], 1, '.', '' );
				}
				?>
				<path class="mc-performance-chart__area" d="<?php echo esc_attr( $area ); ?>"></path>
				<polyline points="<?php echo esc_attr( implode( ' ', $stroke ) ); ?>"></polyline>
			<?php endforeach; ?>
			<?php foreach ( $points as $point ) : ?>
				<circle cx="<?php echo esc_attr( number_format( $point[0], 1, '.', '' ) ); ?>" cy="<?php echo esc_attr( number_format( $point[1], 1, '.', '' ) ); ?>" r="4"></circle>
			<?php endforeach; ?>
			<?php foreach ( $day_labels as $index => $day ) : ?>
				<?php if ( '' !== $day && in_array( $index, $tick_days, true ) ) : ?>
					<?php $day_x = $left + ( ( $right - $left ) * $index / $last ); ?>
					<text class="mc-performance-chart__axis" x="<?php echo esc_attr( number_format( $day_x, 1, '.', '' ) ); ?>" y="174" text-anchor="middle"><?php echo esc_html( $day ); ?></text>
				<?php endif; ?>
			<?php endforeach; ?>
		</svg>
		<?php
	}

	/**
	 * @param array<int,array<string,mixed>> $history Bounded history.
	 * @return void
	 */
	private function render_history( array $history ): void {
		$recent  = array_slice( $history, -7 );
		$metrics = array(
			'memory_limit'  => array( __( 'PHP memory_limit', 'speedlyst' ), 'memory_limit' ),
			'memory_peak_mb' => array( __( 'Peak memory', 'speedlyst' ), 'megabytes' ),
			'php_ver'       => array( __( 'PHP version', 'speedlyst' ), 'plain' ),
			'object_cache'  => array( __( 'Object cache', 'speedlyst' ), 'object_cache' ),
			'autoload_bytes' => array( __( 'Autoload data', 'speedlyst' ), 'bytes' ),
		);
		?>
		<section class="mc-panel mc-panel--history">
			<div class="mc-panel__heading">
				<h2><?php echo Icons::svg( 'clock' ); ?><?php echo esc_html__( 'Local history', 'speedlyst' ); ?></h2>
				<p><?php echo esc_html__( 'At most one snapshot per day is stored for up to 90 days. WP-Cron runs intermittently; missing days are not backfilled.', 'speedlyst' ); ?></p>
			</div>
		<?php if ( empty( $recent ) ) : ?>
			<p class="mc-empty-state"><?php echo esc_html__( 'The history has no measurements yet.', 'speedlyst' ); ?></p>
			</section>
			<?php return; ?>
		<?php endif; ?>
		<table class="widefat striped mc-table mc-history-table">
			<thead>
				<tr>
					<th scope="col"><?php echo esc_html__( 'Metric', 'speedlyst' ); ?></th>
					<?php foreach ( $recent as $entry ) : ?>
						<th scope="col"><?php echo esc_html( (string) $entry['date'] ); ?></th>
					<?php endforeach; ?>
					<th scope="col"><?php echo esc_html( $this->trend_heading( count( $recent ) ) ); ?></th>
				</tr>
			</thead>
			<tbody>
				<?php foreach ( $metrics as $key => $metric ) : ?>
					<tr>
						<th scope="row"><?php echo esc_html( $metric[0] ); ?></th>
						<?php foreach ( $recent as $entry ) : ?>
							<?php $data = isset( $entry['data'] ) && is_array( $entry['data'] ) ? $entry['data'] : array(); ?>
							<td><?php echo nl2br( esc_html( $this->format_field( $this->field( $data, $key ), $metric[1] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- nl2br over escaped text. ?></td>
						<?php endforeach; ?>
						<td class="mc-history-table__trend"><?php $this->render_history_trend( $recent, $key, $metric[1] ); ?></td>
					</tr>
				<?php endforeach; ?>
			</tbody>
		</table>
		</section>
		<?php
	}

	/**
	 * Localized compact heading for the visible trend range.
	 *
	 * @param int $days Number of visible snapshots.
	 * @return string
	 */
	private function trend_heading( int $days ): string {
		// Skloňování řídí gettext podle Plural-Forms cílového jazyka. Čeština má
		// tři tvary (1 den, 2 až 4 dny, 5 a víc dní), takže ruční ternár tady
		// nikdy nemohl vyjít; do 0.36.0 měl navíc obě větve stejné.
		/* translators: %d: number of days in the visible trend window. */
		return sprintf( _n( 'Trend (%d day)', 'Trend (%d days)', $days, 'speedlyst' ), $days );
	}

	/**
	 * Render a small honest trend line from the values already shown in history.
	 * Missing measurements remain gaps and are never converted to zero.
	 *
	 * @param array<int,array<string,mixed>> $history Visible history entries.
	 * @param string                         $key     Metric key.
	 * @param string                         $type    Metric display type.
	 * @return void
	 */
	private function render_history_trend( array $history, string $key, string $type ): void {
		$values = array();
		foreach ( $history as $entry ) {
			$data     = isset( $entry['data'] ) && is_array( $entry['data'] ) ? $entry['data'] : array();
			$values[] = $this->trend_numeric_value( $this->field( $data, $key ), $type );
		}

		$measured = array_values(
			array_filter(
				$values,
				static function ( $value ) {
					return null !== $value;
				}
			)
		);

		if ( count( $measured ) < 2 ) {
			?>
			<span class="mc-trend-empty" aria-label="<?php echo esc_attr__( 'Trend unavailable', 'speedlyst' ); ?>"></span>
			<?php
			return;
		}

		$minimum  = min( $measured );
		$maximum  = max( $measured );
		$range    = $maximum - $minimum;
		$last     = max( 1, count( $values ) - 1 );
		$baseline = 30.0;
		$segments = array();
		$segment  = array();
		$points   = array();

		foreach ( $values as $index => $value ) {
			if ( null === $value ) {
				if ( count( $segment ) > 1 ) {
					$segments[] = $segment;
				}
				$segment = array();
				continue;
			}

			$x                = 3 + ( 164 * $index / $last );
			$y                = 0.0 === $range ? 14.0 : 25 - ( ( $value - $minimum ) / $range * 20 );
			$segment[]        = array( $x, $y );
			$points[ $index ] = array( $x, $y );
		}

		if ( count( $segment ) > 1 ) {
			$segments[] = $segment;
		}
		?>
		<svg class="mc-trend-chart" viewBox="0 0 170 32" role="img" aria-label="<?php echo esc_attr__( 'Trend of available measurements', 'speedlyst' ); ?>" focusable="false">
			<?php foreach ( $segments as $line ) : ?>
				<?php
				$area = 'M ' . number_format( $line[0][0], 1, '.', '' ) . ',' . number_format( $baseline, 1, '.', '' );
				foreach ( $line as $point ) {
					$area .= ' L ' . number_format( $point[0], 1, '.', '' ) . ',' . number_format( $point[1], 1, '.', '' );
				}
				$area .= ' L ' . number_format( $line[ count( $line ) - 1 ][0], 1, '.', '' ) . ',' . number_format( $baseline, 1, '.', '' ) . ' Z';
				$stroke = array();
				foreach ( $line as $point ) {
					$stroke[] = number_format( $point[0], 1, '.', '' ) . ',' . number_format( $point[1], 1, '.', '' );
				}
				?>
				<path class="mc-trend-chart__area" d="<?php echo esc_attr( $area ); ?>"></path>
				<polyline points="<?php echo esc_attr( implode( ' ', $stroke ) ); ?>"></polyline>
			<?php endforeach; ?>
			<?php foreach ( $points as $point ) : ?>
				<circle cx="<?php echo esc_attr( number_format( $point[0], 1, '.', '' ) ); ?>" cy="<?php echo esc_attr( number_format( $point[1], 1, '.', '' ) ); ?>" r="2.2"></circle>
			<?php endforeach; ?>
		</svg>
		<?php
	}

	/**
	 * Convert only a valid measured field to a comparable trend value.
	 *
	 * @param array<string,mixed> $field Measured field.
	 * @param string              $type  Metric display type.
	 * @return float|null
	 */
	private function trend_numeric_value( array $field, string $type ): ?float {
		if ( 'ok' !== ( isset( $field['status'] ) ? $field['status'] : '' ) || ! array_key_exists( 'value', $field ) || null === $field['value'] ) {
			return null;
		}

		$value = $field['value'];
		if ( in_array( $type, array( 'megabytes', 'integer', 'bytes' ), true ) && ( is_int( $value ) || is_float( $value ) ) ) {
			return (float) $value;
		}

		if ( 'memory_limit' === $type && ( is_string( $value ) || is_int( $value ) ) ) {
			$bytes = $this->recommendations->memory_limit_bytes( (string) $value );

			return $bytes >= 0 ? (float) $bytes : null;
		}

		if ( 'plain' === $type && is_string( $value ) && preg_match( '/^(\d+)\.(\d+)\.(\d+)$/D', $value, $matches ) ) {
			return (float) ( ( (int) $matches[1] * 1000000 ) + ( (int) $matches[2] * 1000 ) + (int) $matches[3] );
		}

		if ( 'object_cache' === $type && is_array( $value ) && isset( $value['external'] ) && is_bool( $value['external'] ) ) {
			return $value['external'] ? 1.0 : 0.0;
		}

		return null;
	}

	/**
	 * @param array<int,array<string,string>> $recommendations Recommendations.
	 * @return void
	 */
	private function render_recommendations( array $recommendations ): void {
		?>
		<section class="mc-panel mc-panel--recommendations">
			<h2><?php echo Icons::svg( 'star-filled' ); ?><?php echo esc_html__( 'Recommendations', 'speedlyst' ); ?></h2>
		<?php if ( empty( $recommendations ) ) : ?>
			<div class="mc-recommendation-empty">
				<?php echo Icons::svg( 'yes-alt' ); ?>
				<div>
					<strong><?php echo esc_html__( 'Everything looks good.', 'speedlyst' ); ?></strong>
					<p><?php echo esc_html__( 'The available local measurements do not indicate any urgent action.', 'speedlyst' ); ?></p>
					<a class="button button-secondary" href="#mc-site-health"><?php echo esc_html__( 'Show all metrics', 'speedlyst' ); ?></a>
				</div>
			</div>
			</section>
			<?php return; ?>
		<?php endif; ?>
		<ul class="mc-recommendation-list">
			<?php foreach ( $recommendations as $recommendation ) : ?>
				<?php $priority = $this->recommendation_priority( $recommendation ); ?>
				<li>
					<div class="mc-recommendation-list__head">
						<strong><?php echo esc_html( $recommendation['title'] ); ?></strong>
						<span class="mc-priority-badge mc-priority-badge--<?php echo esc_attr( $priority['level'] ); ?>"><?php echo esc_html( $priority['label'] ); ?></span>
					</div>
					<?php echo esc_html( $recommendation['text'] ); ?>
				</li>
			<?php endforeach; ?>
		</ul>
		</section>
		<?php
	}

	/**
	 * @param array<string,mixed> $result     Last successful local PSI result.
	 * @param array<string,mixed> $last_error Last failed PSI result when newer.
	 * @return void
	 */
	private function render_psi( array $result, array $last_error = array() ): void {
		?>
		<section class="mc-panel mc-panel--psi">
			<div class="mc-psi-head">
				<span class="mc-psi-head__mark" aria-hidden="true">
					<svg viewBox="0 0 48 48" focusable="false"><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="3"></circle><path d="M24 12v12l8 5" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path></svg>
				</span>
				<div>
					<h2><?php echo esc_html__( 'Google PageSpeed Insights', 'speedlyst' ); ?></h2>
					<p class="mc-panel__intro"><?php echo esc_html__( 'Measures your homepage speed from Google\'s perspective. Measurements run only when you click the button. We perform the measurement and Google receives your site address, never your content or visitor data.', 'speedlyst' ); ?></p>
				</div>
			</div>
		<div class="mc-psi-card mc-psi-card--form">
			<form class="mc-psi-form mc-psi-form--run" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
				<input type="hidden" name="action" value="speedlyst_free_run_psi">
				<?php wp_nonce_field( 'speedlyst_free_run_psi' ); ?>
				<?php $quota_left = $this->psi_quota_left(); ?>
				<button class="button button-primary" type="submit"<?php disabled( $quota_left < 1 ); ?>><?php echo esc_html__( 'Measure site speed', 'speedlyst' ); ?></button>
				<span class="mc-psi-form__note"><strong><?php echo esc_html( $this->last_measurement_label( $result ) ); ?></strong>
					<?php
					echo esc_html(
						$quota_left < 1
							? __( 'The daily measurement limit is used up; it resets at midnight.', 'speedlyst' )
							/* translators: 1: measurements left today, 2: daily ceiling. */
							: sprintf( __( 'A new measurement takes about half a minute. Remaining today: %1$d of %2$d.', 'speedlyst' ), $quota_left, self::PSI_DAILY_LIMIT )
					);
					?>
				</span>
			</form>
		</div>

		<?php $this->render_psi_result( $last_error ); ?>
		<?php $this->render_psi_result( $result ); ?>
		<?php $this->render_psi_trend(); ?>
		</section>
		<?php
	}

	/**
	 * @param array<string,mixed> $result Last PSI result.
	 * @return void
	 */
	private function render_psi_result( array $result ): void {
		if ( empty( $result ) ) {
			return;
		}

		if ( 'ok' !== ( isset( $result['status'] ) ? $result['status'] : '' ) ) {
			$code     = isset( $result['error_code'] ) && is_string( $result['error_code'] ) ? $result['error_code'] : '';
			$messages = array(
				'daily_limit_reached'   => __( 'This site\'s daily measurement limit has been reached. Try again tomorrow.', 'speedlyst' ),
				'domain_not_verified'   => __( 'We could not verify that this domain belongs to this site, so the measurement did not run.', 'speedlyst' ),
				'service_failed'        => __( 'Our measurement service did not respond. Try again in a moment.', 'speedlyst' ),
				'redirected_offsite'    => __( 'Google could not reach your site: the request ended on another domain, typically a login or security gateway (Cloudflare Access, a staging password, or bot protection). The measured values would belong to that gateway, not the site, so they are not displayed. Allow Google access and repeat the measurement.', 'speedlyst' ),
				'request_failed'        => __( 'The connection to Google PSI failed. Check the server\'s outbound connection and try again.', 'speedlyst' ),
				'missing_configuration' => __( 'The site address or verification code is missing.', 'speedlyst' ),
			);
			$message  = isset( $messages[ $code ] ) ? $messages[ $code ] : __( 'The PSI measurement failed. Try again.', 'speedlyst' );
			$hint     = $this->blocking_hint( $code, isset( $result['probe_status'] ) && is_int( $result['probe_status'] ) ? $result['probe_status'] : null );
			?>
			<div class="notice notice-error inline">
				<p><?php echo esc_html( $message ); ?></p>
				<?php if ( '' !== $hint ) : ?>
					<p><strong><?php echo esc_html__( 'Why the numbers are missing:', 'speedlyst' ); ?></strong> <?php echo esc_html( $hint ); ?></p>
					<p><?php echo esc_html__( 'Quick check: open the address below in your browser. If it loads for you but the measurement still fails, your site is blocking external visits based on IP address or browser.', 'speedlyst' ); ?><br>
					<code><?php echo esc_html( rest_url( PsiNonce::NAMESPACE . PsiNonce::ROUTE ) ); ?></code></p>
				<?php endif; ?>
			</div>
			<?php
			return;
		}

		$metrics = isset( $result['metrics'] ) && is_array( $result['metrics'] ) ? $result['metrics'] : array();
		$score   = isset( $metrics['lab_performance'] ) && is_array( $metrics['lab_performance'] ) ? $metrics['lab_performance'] : array();
		$vitals  = array(
			array(
				'key'      => 'lab_lcp_ms',
				'label'    => 'LCP',
				'context'  => __( 'Lab data', 'speedlyst' ),
				'hint'     => __( 'Largest Contentful Paint: how long it takes to render the largest page element, typically the main image or heading.', 'speedlyst' ),
				'type'     => 'milliseconds',
				'good'     => 2500.0,
				'poor'     => 4000.0,
				'pair_key'   => 'field_lcp_ms',
				'pair_label' => __( 'For visitors (75th p)', 'speedlyst' ),
			),
			array(
				'key'      => 'lab_cls',
				'label'    => 'CLS',
				'context'  => __( 'Lab data', 'speedlyst' ),
				'hint'     => __( 'Cumulative Layout Shift: how much the content moves during loading. Zero means nothing shifts.', 'speedlyst' ),
				'type'     => 'decimal',
				'good'     => 0.1,
				'poor'     => 0.25,
				'pair_key'   => 'field_cls',
				'pair_label' => __( 'For visitors (75th p)', 'speedlyst' ),
			),
			array(
				'key'      => 'field_inp_ms',
				'label'    => 'INP',
				'context'  => __( 'Field data, 75th percentile', 'speedlyst' ),
				'hint'     => __( 'Interaction to Next Paint: how quickly the page responds to clicks from real visitors. Without enough CrUX data, it remains unmeasured.', 'speedlyst' ),
				'type'     => 'milliseconds',
				'good'     => 200.0,
				'poor'     => 500.0,
				'pair_key'   => '',
				'pair_label' => __( 'Lab measurement', 'speedlyst' ),
				'pair_note'  => __( 'Lighthouse does not measure INP', 'speedlyst' ),
			),
		);
		?>
		<div class="mc-psi-results">
			<div class="mc-psi-overview">
				<div class="mc-psi-score">
					<h3><?php echo esc_html__( 'Performance', 'speedlyst' ); ?></h3>
					<?php $this->render_psi_score( $score ); ?>
					<p><?php echo esc_html__( 'Lighthouse lab score', 'speedlyst' ); ?></p>
					<?php $median = $this->psi_week_median(); ?>
					<?php if ( null !== $median ) : ?>
						<p class="mc-psi-score__median">
							<?php
							/* translators: 1: median score, 2: number of measurements. */
							echo esc_html( sprintf( __( '7-day median: %1$s (from %2$d measurements)', 'speedlyst' ), number_format_i18n( $median['value'] ), (int) $median['count'] ) );
							?>
						</p>
					<?php endif; ?>
				</div>
				<div class="mc-psi-vitals">
					<div class="mc-psi-vitals__heading">
						<h3><?php echo esc_html__( 'Core Web Vitals', 'speedlyst' ); ?></h3>
						<span class="mc-psi-chip"><?php echo Icons::svg( 'smartphone' ); ?><?php echo esc_html__( 'Mobile figures. Lab is a single test on Google\'s machine, so it varies. Field represents real visitors over 28 days and is stable.', 'speedlyst' ); ?></span>
					</div>
					<div class="mc-psi-vitals__grid">
						<?php foreach ( $vitals as $vital ) : ?>
							<?php
							$field = isset( $metrics[ $vital['key'] ] ) && is_array( $metrics[ $vital['key'] ] ) ? $metrics[ $vital['key'] ] : array();
							$this->render_psi_vital( $field, $vital, $metrics );
							?>
						<?php endforeach; ?>
					</div>
				</div>
			</div>
			<div class="mc-psi-opportunities">
				<?php $this->render_opportunities( $result ); ?>
			</div>
		</div>
		<?php
	}

	/**
	 * Plain explanation of a blocked measurement, driven by what our service
	 * actually saw on this site's own address. Never guesses a cause.
	 *
	 * @param string   $code         Error code.
	 * @param int|null $probe_status HTTP status our service received.
	 * @return string
	 */
	private function blocking_hint( string $code, ?int $probe_status ): string {
		if ( 'redirected_offsite' === $code ) {
			return __( 'Google could not reach your site and ended on a login or security page. This is typically caused by Cloudflare Access, a staging password, bot protection, or blocked foreign IP addresses. No figures will be available until Google can get through.', 'speedlyst' );
		}

		if ( 'domain_not_verified' !== $code ) {
			return '';
		}

		if ( 403 === $probe_status || 401 === $probe_status || 406 === $probe_status || 429 === $probe_status ) {
			/* translators: %d: HTTP status code. */
			return sprintf( __( 'Your site responded to our verification service with code %d, which means it rejected the service. This is most often caused by a security plugin (such as Wordfence), a hosting firewall, or bot protection. Allow access to the address below.', 'speedlyst' ), $probe_status );
		}

		if ( 404 === $probe_status ) {
			return __( 'The verification address does not exist on your site. This usually means the WordPress REST API is disabled or a plugin is blocking it.', 'speedlyst' );
		}

		if ( null === $probe_status || 0 === $probe_status ) {
			return __( 'Your site did not respond at all at the verification address. This is often caused by a hosting firewall, blocked foreign IP addresses, or a site available only from certain countries.', 'speedlyst' );
		}

		/* translators: %d: HTTP status code. */
		return sprintf( __( 'Your site returned code %d at the verification address instead of the expected data.', 'speedlyst' ), $probe_status );
	}

	/**
	 * When the local numbers on screen were actually taken.
	 *
	 * @param array<string,mixed> $snapshot Current snapshot.
	 * @return string
	 */
	private function snapshot_stamp( array $snapshot ): string {
		$when = isset( $snapshot['observed_at'] ) && is_string( $snapshot['observed_at'] ) ? strtotime( $snapshot['observed_at'] ) : false;

		if ( false === $when ) {
			return __( 'Not measured yet', 'speedlyst' );
		}

		/* translators: %s: date and time of the local measurement. */
		return sprintf( __( 'Updated %s', 'speedlyst' ), wp_date( 'j. n. Y H:i', $when ) );
	}

	private function last_measurement_label( array $result ): string {
		$when = isset( $result['observed_at'] ) && is_string( $result['observed_at'] ) ? strtotime( $result['observed_at'] ) : false;

		if ( false === $when ) {
			return __( 'No measurements yet.', 'speedlyst' );
		}

		/* translators: %s: date and time of the last measurement. */
		return sprintf( __( 'Last measurement: %s', 'speedlyst' ), wp_date( 'j. n. Y H:i', $when ) );
	}

	/**
	 * Median score of the measurements from the last seven days.
	 *
	 * A single Lighthouse run swings by several points on the same site, so the
	 * median of what was actually measured is the steadier number. It is never
	 * computed from fewer than two runs and never fills in days without one.
	 *
	 * @return array<string,float|int>|null
	 */
	private function psi_week_median(): ?array {
		// Literál místo DAY_IN_SECONDS: konstanty WordPressu nemusí být načtené.
		$cutoff = time() - 604800;
		$scores = array();

		foreach ( $this->psi_history->entries() as $entry ) {
			$when = isset( $entry['observed_at'] ) && is_string( $entry['observed_at'] ) ? strtotime( $entry['observed_at'] ) : false;
			if ( false === $when || $when < $cutoff ) {
				continue;
			}
			if ( isset( $entry['score'] ) && ( is_int( $entry['score'] ) || is_float( $entry['score'] ) ) ) {
				$scores[] = (float) $entry['score'];
			}
		}

		$count = count( $scores );
		if ( $count < 2 ) {
			return null;
		}

		sort( $scores );
		$middle = (int) floor( ( $count - 1 ) / 2 );
		$median = 0 === $count % 2 ? ( $scores[ $middle ] + $scores[ $middle + 1 ] ) / 2 : $scores[ $middle ];

		return array( 'value' => round( $median ), 'count' => $count );
	}

	/**
	 * @param array<string,mixed> $field Lighthouse performance score.
	 * @return void
	 */
	private function render_psi_score( array $field ): void {
		$value    = $this->psi_numeric_value( $field );
		$score    = null === $value ? 0.0 : min( 100.0, max( 0.0, $value ) );
		$state    = null === $value ? 'unmeasured' : ( $score >= 90 ? 'good' : ( $score >= 50 ? 'needs' : 'poor' ) );
		$display  = null === $value ? __( 'not measured', 'speedlyst' ) : number_format_i18n( round( $score ) );
		$label    = $this->psi_state_label( $state );
		/* translators: 1: numeric performance score or "not measured", 2: localized score status. */
		$score_aria = sprintf( __( 'Performance: %1$s, status %2$s', 'speedlyst' ), $display, $label );
		?>
		<div class="mc-psi-score-ring mc-psi-state--<?php echo esc_attr( $state ); ?>">
			<svg viewBox="0 0 128 128" role="img" aria-label="<?php echo esc_attr( $score_aria ); ?>" focusable="false">
				<circle class="mc-psi-score-ring__track" cx="64" cy="64" r="54"></circle>
				<circle class="mc-psi-score-ring__value" cx="64" cy="64" r="54" pathLength="100" stroke-dasharray="<?php echo esc_attr( number_format( $score, 1, '.', '' ) ); ?> 100"></circle>
			</svg>
			<span class="mc-psi-score-ring__number"><?php echo esc_html( $display ); ?></span>
		</div>
		<div class="mc-psi-score-status mc-psi-state--<?php echo esc_attr( $state ); ?>">
			<span aria-hidden="true"></span>
			<strong><?php echo esc_html( $label ); ?></strong>
		</div>
		<?php
	}

	/**
	 * @param array<string,mixed> $field   Normalized PSI field.
	 * @param array<string,mixed> $vital   Metric definition.
	 * @param array<string,mixed> $metrics All normalized PSI metrics.
	 * @return void
	 */
	private function render_psi_vital( array $field, array $vital, array $metrics = array() ): void {
		$value   = $this->psi_numeric_value( $field );
		$state   = $this->psi_threshold_state( $value, (float) $vital['good'], (float) $vital['poor'] );
		$display = $this->format_field( $field, (string) $vital['type'] );
		$width   = null === $value ? 0.0 : min( 100.0, max( 3.0, $value / (float) $vital['poor'] * 100 ) );
		$good_at = min( 100.0, max( 0.0, (float) $vital['good'] / (float) $vital['poor'] * 100 ) );
		?>
		<article class="mc-psi-vital mc-psi-state--<?php echo esc_attr( $state ); ?>">
			<div class="mc-psi-vital__header">
				<div>
					<strong><?php echo esc_html( (string) $vital['label'] ); ?></strong>
					<?php if ( isset( $vital['hint'] ) ) : ?>
						<span class="mc-psi-hint" tabindex="0" role="button" aria-label="<?php echo esc_attr( (string) $vital['hint'] ); ?>">
							<span class="mc-psi-hint__mark"><?php echo Icons::svg( 'editor-help' ); ?></span>
							<span class="mc-psi-hint__bubble"><?php echo esc_html( (string) $vital['hint'] ); ?></span>
						</span>
					<?php endif; ?>
					<small><?php echo esc_html( (string) $vital['context'] ); ?></small>
				</div>
				<span class="mc-psi-vital__value"><?php echo esc_html( $display ); ?></span>
			</div>
			<div class="mc-psi-vital__track" aria-hidden="true">
				<span style="<?php echo esc_attr( 'width:' . number_format( $width, 1, '.', '' ) . '%' ); ?>"></span>
				<i class="mc-psi-vital__good" style="<?php echo esc_attr( 'left:' . number_format( $good_at, 1, '.', '' ) . '%' ); ?>"></i>
				<i class="mc-psi-vital__poor"></i>
			</div>
			<div class="mc-psi-vital__footer">
				<span class="mc-psi-badge"><?php echo esc_html( $this->psi_state_label( $state ) ); ?></span>
				<small><?php
					/* translators: %s: localized Core Web Vital threshold including its unit. */
					echo esc_html( sprintf( __( 'Good up to %s', 'speedlyst' ), $this->format_psi_threshold( (float) $vital['good'], (string) $vital['type'] ) ) );
				?></small>
			</div>
			<dl class="mc-psi-vital__meta">
				<div>
					<dt><?php echo esc_html( isset( $vital['pair_label'] ) ? (string) $vital['pair_label'] : '' ); ?></dt>
					<dd>
						<?php
						if ( isset( $vital['pair_note'] ) ) {
							echo esc_html( (string) $vital['pair_note'] );
						} else {
							$pair_key   = isset( $vital['pair_key'] ) ? (string) $vital['pair_key'] : '';
							$pair_field = '' !== $pair_key && isset( $metrics[ $pair_key ] ) && is_array( $metrics[ $pair_key ] ) ? $metrics[ $pair_key ] : array();
							echo esc_html( $this->format_field( $pair_field, (string) $vital['type'] ) );
						}
						?>
					</dd>
				</div>
				<div>
					<dt><?php echo esc_html__( 'Poor above', 'speedlyst' ); ?></dt>
					<dd><?php echo esc_html( $this->format_psi_threshold( (float) $vital['poor'], (string) $vital['type'] ) ); ?></dd>
				</div>
			</dl>
		</article>
		<?php
	}

	/**
	 * @param array<string,mixed> $field Normalized PSI field.
	 * @return float|null
	 */
	private function psi_numeric_value( array $field ): ?float {
		if ( 'ok' !== ( isset( $field['status'] ) ? $field['status'] : '' ) || ! isset( $field['value'] ) || ! ( is_int( $field['value'] ) || is_float( $field['value'] ) ) ) {
			return null;
		}

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

	/**
	 * @param float|null $value Measured value.
	 * @param float      $good  Good threshold.
	 * @param float      $poor  Poor threshold.
	 * @return string
	 */
	private function psi_threshold_state( ?float $value, float $good, float $poor ): string {
		if ( null === $value ) {
			return 'unmeasured';
		}

		if ( $value <= $good ) {
			return 'good';
		}

		return $value <= $poor ? 'needs' : 'poor';
	}

	/**
	 * @param string $state Metric state.
	 * @return string
	 */
	private function psi_state_label( string $state ): string {
		$labels = array(
			'good'       => __( 'Good', 'speedlyst' ),
			'needs'      => __( 'Needs improvement', 'speedlyst' ),
			'poor'       => __( 'Poor', 'speedlyst' ),
			'unmeasured' => __( 'Not measured', 'speedlyst' ),
		);

		return isset( $labels[ $state ] ) ? $labels[ $state ] : $labels['unmeasured'];
	}

	/**
	 * @param float  $value Threshold value.
	 * @param string $type  Metric display type.
	 * @return string
	 */
	private function format_psi_threshold( float $value, string $type ): string {
		if ( 'decimal' === $type ) {
			return number_format_i18n( $value, 2 );
		}

		return number_format_i18n( round( $value ) ) . ' ms';
	}

	/**
	 * Trend of the stored PSI runs. Local only, same visual language as the
	 * local performance trend; a metric the API did not return stays a gap.
	 *
	 * @return void
	 */
	private function render_psi_trend(): void {
		$entries = $this->psi_history->entries();

		if ( count( $entries ) < 2 ) {
			return;
		}

		$visible = array_slice( $entries, -14 );
		$labels  = $this->performance_day_labels( $visible );
		$metrics = array(
			array(
				'key'   => 'score',
				'label' => __( 'Performance (score)', 'speedlyst' ),
				'unit'  => __( 'points (higher is better)', 'speedlyst' ),
				'type'  => 'score',
				'color' => 'blue',
			),
			array(
				'key'   => 'lcp_ms',
				'label' => __( 'LCP', 'speedlyst' ),
				'unit'  => __( 'ms (lower is better)', 'speedlyst' ),
				'type'  => 'milliseconds',
				'color' => 'violet',
			),
			array(
				'key'   => 'cls',
				'label' => __( 'CLS', 'speedlyst' ),
				'unit'  => __( 'layout shift (lower is better)', 'speedlyst' ),
				'type'  => 'decimal',
				'color' => 'teal',
			),
		);
		?>
		<div class="mc-psi-card mc-psi-trend">
			<div class="mc-panel__heading mc-panel__heading--performance">
				<div>
					<h3><?php echo Icons::svg( 'chart-area' ); ?><?php echo esc_html__( 'PSI measurement trend', 'speedlyst' ); ?></h3>
					<p><?php echo esc_html__( 'Every completed measurement is stored, up to the latest 60. The latest 14 are displayed.', 'speedlyst' ); ?></p>
				</div>
				<span class="mc-performance-period"><?php
					/* translators: %d: number of stored PageSpeed Insights measurements. */
					echo esc_html( sprintf( __( 'Measurements: %d', 'speedlyst' ), count( $entries ) ) );
				?></span>
			</div>
			<div class="mc-performance-grid">
				<?php foreach ( $metrics as $metric ) : ?>
					<?php
					$values = array();
					foreach ( $visible as $entry ) {
						$value    = isset( $entry[ $metric['key'] ] ) ? $entry[ $metric['key'] ] : null;
						$values[] = ( is_int( $value ) || is_float( $value ) ) ? (float) $value : null;
					}
					$latest   = $this->latest_measured_value( $values );
					$lower_better = 'score' !== $metric['type'];
					$headline     = $this->performance_headline_delta( $values, $labels, (string) $metric['type'], $lower_better );
					$footer       = $this->performance_footer_delta( $values, (string) $metric['type'], $lower_better );
					?>
					<article class="mc-performance-card mc-performance-card--<?php echo esc_attr( $metric['color'] ); ?>">
						<div class="mc-performance-card__header">
							<span class="mc-performance-card__label"><?php echo esc_html( (string) $metric['label'] ); ?></span>
							<strong class="<?php echo esc_attr( null === $latest ? 'mc-is-unmeasured' : '' ); ?>">
								<?php echo esc_html( $this->format_performance_value( $latest, (string) $metric['type'] ) ); ?>
							</strong>
							<small class="mc-performance-card__unit"><?php echo esc_html( (string) $metric['unit'] ); ?></small>
							<span class="mc-performance-card__delta mc-delta--<?php echo esc_attr( $headline['tone'] ); ?>">
								<?php if ( 'flat' === $headline['tone'] ) : ?>
									<i aria-hidden="true"></i>
								<?php elseif ( 'none' !== $headline['direction'] ) : ?>
									<span class="mc-delta__arrow" aria-hidden="true"><?php echo 'down' === $headline['direction'] ? '&#8595;' : '&#8593;'; ?></span>
								<?php endif; ?>
								<?php echo esc_html( $headline['text'] ); ?>
							</span>
						</div>
						<?php $this->render_performance_chart( $values, $labels, (string) $metric['type'], (string) $metric['label'] ); ?>
						<p class="mc-performance-card__footer mc-delta--<?php echo esc_attr( $footer['tone'] ); ?>">
							<i aria-hidden="true"></i><?php echo esc_html( $footer['text'] ); ?>
						</p>
					</article>
				<?php endforeach; ?>
			</div>
		</div>
		<?php
	}

	/**
	 * Prioritized "what to improve" list from the PSI opportunity projection.
	 *
	 * Labels and action hints are our own, keyed by the safe audit id. Nothing
	 * from the raw PSI response text is echoed.
	 *
	 * @param array<string,mixed> $result Last PSI result.
	 * @return void
	 */
	private function render_opportunities( array $result ): void {
		$items = isset( $result['opportunities'] ) && is_array( $result['opportunities'] ) ? $result['opportunities'] : array();
		if ( empty( $items ) ) {
			return;
		}

		$labels = $this->opportunity_labels();
		$icons  = $this->opportunity_icons();
		?>
		<h3><?php echo Icons::svg( 'chart-line' ); ?><?php echo esc_html__( 'Top improvement opportunities', 'speedlyst' ); ?></h3>
		<p class="description"><?php echo esc_html__( 'Sorted by how much time or data the fix could save on mobile. These are Google estimates, not a guarantee; measure again after the fix.', 'speedlyst' ); ?></p>
		<table class="widefat striped mc-table mc-opportunities-table">
			<thead>
				<tr>
					<th scope="col"><?php echo esc_html__( 'What to improve', 'speedlyst' ); ?></th>
					<th scope="col"><?php echo esc_html__( 'Estimated potential', 'speedlyst' ); ?></th>
					<th scope="col"><?php echo esc_html__( 'Recommended action', 'speedlyst' ); ?></th>
					<th scope="col"><span class="screen-reader-text"><?php echo esc_html__( 'Priority', 'speedlyst' ); ?></span></th>
				</tr>
			</thead>
			<tbody>
				<?php foreach ( $items as $item ) : ?>
					<?php $id = isset( $item['audit_id'] ) && is_string( $item['audit_id'] ) ? $item['audit_id'] : ''; ?>
					<?php $label = isset( $labels[ $id ] ) ? $labels[ $id ] : array( $id, '' ); ?>
					<?php $priority = $this->opportunity_priority( $item ); ?>
					<tr>
						<th scope="row">
							<span class="mc-opportunity-icon"><?php echo Icons::svg( isset( $icons[ $id ] ) ? (string) $icons[ $id ] : 'performance' ); ?></span>
							<?php echo esc_html( $label[0] ); ?>
						</th>
						<td>
							<span class="mc-opportunity-savings"><?php echo esc_html( $this->format_savings( $item ) ); ?></span>
							<span class="mc-priority-badge mc-priority-badge--<?php echo esc_attr( $priority['level'] ); ?>"><?php echo esc_html( $priority['label'] ); ?></span>
						</td>
						<td><?php echo esc_html( $label[1] ); ?></td>
						<td class="mc-opportunities-table__marker"><?php echo Icons::svg( 'arrow-right-alt2' ); ?></td>
					</tr>
				<?php endforeach; ?>
			</tbody>
		</table>
		<?php
	}

	/**
	 * Priority from the measured savings only; without a number it stays N/A.
	 *
	 * @param array<string,mixed> $item One opportunity projection.
	 * @return array<string,string> level and label.
	 */
	private function opportunity_priority( array $item ): array {
		$ms    = isset( $item['savings_ms'] ) && ( is_int( $item['savings_ms'] ) || is_float( $item['savings_ms'] ) ) ? (float) $item['savings_ms'] : null;
		$bytes = isset( $item['savings_bytes'] ) && ( is_int( $item['savings_bytes'] ) || is_float( $item['savings_bytes'] ) ) ? (float) $item['savings_bytes'] : null;

		if ( null === $ms && null === $bytes ) {
			return array( 'level' => 'none', 'label' => __( 'N/A', 'speedlyst' ) );
		}

		if ( ( null !== $ms && $ms >= 150 ) || ( null !== $bytes && $bytes >= 20480 ) ) {
			return array( 'level' => 'high', 'label' => __( 'HIGH', 'speedlyst' ) );
		}

		if ( ( null !== $ms && $ms >= 50 ) || ( null !== $bytes && $bytes >= 10240 ) ) {
			return array( 'level' => 'medium', 'label' => __( 'MEDIUM', 'speedlyst' ) );
		}

		return array( 'level' => 'low', 'label' => __( 'LOW', 'speedlyst' ) );
	}

	/**
	 * @return array<string,string> audit_id => dashicon suffix.
	 */
	private function opportunity_icons(): array {
		return array(
			'render-blocking-resources'       => 'editor-code',
			'render-blocking-insight'         => 'editor-code',
			'unused-javascript'               => 'media-code',
			'unminified-javascript'           => 'media-code',
			'legacy-javascript-insight'       => 'media-code',
			'duplicated-javascript-insight'   => 'media-code',
			'bootup-time'                     => 'media-code',
			'mainthread-work-breakdown'       => 'media-code',
			'forced-reflow-insight'           => 'update',
			'unused-css-rules'                => 'admin-appearance',
			'unminified-css'                  => 'admin-appearance',
			'font-display-insight'            => 'editor-textcolor',
			'uses-responsive-images'          => 'format-image',
			'unsized-images'                  => 'format-image',
			'uses-optimized-images'           => 'format-image',
			'modern-image-formats'            => 'format-image',
			'offscreen-images'                => 'format-image',
			'image-delivery-insight'          => 'format-image',
			'lcp-discovery-insight'           => 'format-image',
			'network-dependency-tree-insight' => 'admin-links',
			'redirects'                       => 'admin-links',
			'cache-insight'                   => 'database',
			'uses-text-compression'           => 'archive',
			'server-response-time'            => 'performance',
			'document-latency-insight'        => 'performance',
			'cls-culprits-insight'            => 'move',
			'dom-size-insight'                => 'editor-table',
			'viewport-insight'                => 'smartphone',
		);
	}

	/**
	 * @param array<string,mixed> $item One opportunity projection.
	 * @return string
	 */
	private function format_savings( array $item ): string {
		$parts = array();

		if ( isset( $item['savings_ms'] ) && ( is_int( $item['savings_ms'] ) || is_float( $item['savings_ms'] ) ) ) {
			$parts[] = number_format_i18n( $item['savings_ms'] ) . ' ms';
		}

		if ( isset( $item['savings_bytes'] ) && ( is_int( $item['savings_bytes'] ) || is_float( $item['savings_bytes'] ) ) ) {
			$parts[] = number_format_i18n( round( $item['savings_bytes'] / 1024 ) ) . ' kB';
		}

		if ( empty( $parts ) ) {
			return __( 'not measured', 'speedlyst' );
		}

		/* translators: %s: estimated savings figures. */
		return sprintf( __( '~%s', 'speedlyst' ), implode( ' / ', $parts ) );
	}

	/**
	 * @return array<string,array<int,string>> audit_id => [label, action hint].
	 */
	private function opportunity_labels(): array {
		return array(
			'render-blocking-resources' => array( __( 'Render-blocking resources', 'speedlyst' ), __( 'Defer or inline critical CSS/JS; load the rest with async/defer.', 'speedlyst' ) ),
			'unused-javascript'         => array( __( 'Unused JavaScript', 'speedlyst' ), __( 'Remove or split unused scripts and defer third-party code.', 'speedlyst' ) ),
			'unused-css-rules'          => array( __( 'Unused CSS', 'speedlyst' ), __( 'Remove unused rules and load CSS only where needed.', 'speedlyst' ) ),
			'uses-responsive-images'    => array( __( 'Improperly sized images', 'speedlyst' ), __( 'Provide responsive sizes (srcset) for each display.', 'speedlyst' ) ),
			'unsized-images'            => array( __( 'Images without dimensions', 'speedlyst' ), __( 'Add width and height to reduce layout shift (CLS).', 'speedlyst' ) ),
			'uses-optimized-images'     => array( __( 'Unoptimized images', 'speedlyst' ), __( 'Compress images before uploading or with a plugin.', 'speedlyst' ) ),
			'modern-image-formats'      => array( __( 'Outdated image formats', 'speedlyst' ), __( 'Use WebP or AVIF instead of JPEG/PNG.', 'speedlyst' ) ),
			'offscreen-images'          => array( __( 'Offscreen images', 'speedlyst' ), __( 'Lazy-load images below the fold.', 'speedlyst' ) ),
			'server-response-time'      => array( __( 'Slow server response (TTFB)', 'speedlyst' ), __( 'Speed up the backend: cache, better hosting, fewer queries.', 'speedlyst' ) ),
			'uses-text-compression'     => array( __( 'Text compression is missing', 'speedlyst' ), __( 'Enable gzip or brotli on the server.', 'speedlyst' ) ),
			'unminified-javascript'     => array( __( 'Unminified JavaScript', 'speedlyst' ), __( 'Enable script minification in an optimization plugin or build.', 'speedlyst' ) ),
			'unminified-css'            => array( __( 'Unminified CSS', 'speedlyst' ), __( 'Enable stylesheet minification in an optimization plugin or build.', 'speedlyst' ) ),
			'mainthread-work-breakdown' => array( __( 'Main-thread workload', 'speedlyst' ), __( 'Reduce heavy scripts and third-party code, and split long tasks.', 'speedlyst' ) ),
			'bootup-time'               => array( __( 'Long JavaScript execution', 'speedlyst' ), __( 'Reduce and defer scripts that block page startup.', 'speedlyst' ) ),
			// Lighthouse 12/13 insight audits (nahradily část klasických ID).
			'redirects'                 => array( __( 'Multiple redirects', 'speedlyst' ), __( 'Link directly to the target URL and remove redirect chains (www, http, trailing slash).', 'speedlyst' ) ),
			'document-latency-insight'  => array( __( 'Initial document latency', 'speedlyst' ), __( 'Reduce the server response time for HTML: page cache, no redirects, faster backend.', 'speedlyst' ) ),
			'render-blocking-insight'   => array( __( 'Render-blocking resources', 'speedlyst' ), __( 'Defer or inline critical CSS/JS; load the rest with async/defer.', 'speedlyst' ) ),
			'image-delivery-insight'    => array( __( 'Image delivery', 'speedlyst' ), __( 'Use modern formats (WebP/AVIF), correct dimensions, and lazy-loading.', 'speedlyst' ) ),
			'lcp-discovery-insight'     => array( __( 'LCP element discovery', 'speedlyst' ), __( 'Do not lazy-load the main image; preload it and serve it directly from HTML.', 'speedlyst' ) ),
			'network-dependency-tree-insight' => array( __( 'Network dependency chain', 'speedlyst' ), __( 'Shorten the critical request chain and preload key resources.', 'speedlyst' ) ),
			'cache-insight'             => array( __( 'Short cache lifetime', 'speedlyst' ), __( 'Set longer cache headers for static files.', 'speedlyst' ) ),
			'font-display-insight'      => array( __( 'Font loading', 'speedlyst' ), __( 'Set font-display: swap so text does not wait for the font.', 'speedlyst' ) ),
			'legacy-javascript-insight' => array( __( 'Legacy JavaScript', 'speedlyst' ), __( 'Do not send polyfills to modern browsers; reduce the code size.', 'speedlyst' ) ),
			'duplicated-javascript-insight' => array( __( 'Duplicate JavaScript', 'speedlyst' ), __( 'Remove duplicate libraries loaded multiple times (often by different plugins).', 'speedlyst' ) ),
			'cls-culprits-insight'      => array( __( 'Causes of layout shifts', 'speedlyst' ), __( 'Reserve space for images, ads, and dynamic elements.', 'speedlyst' ) ),
			'dom-size-insight'          => array( __( 'Large DOM', 'speedlyst' ), __( 'Simplify the page structure and reduce the number of elements.', 'speedlyst' ) ),
			'forced-reflow-insight'     => array( __( 'Forced reflow', 'speedlyst' ), __( 'Update scripts that repeatedly read and change the layout.', 'speedlyst' ) ),
			'viewport-insight'          => array( __( 'Viewport configuration', 'speedlyst' ), __( 'Check the meta viewport for mobile display.', 'speedlyst' ) ),
		);
	}

	/**
	 * @return void
	 */
	private function render_language_icon(): void {
		$locale_flags = array(
			'en_US' => 'gb',
			'de_DE' => 'de',
			'es_ES' => 'es',
			'pt_BR' => 'br',
			'fr_FR' => 'fr',
			'it_IT' => 'it',
			'ja' => 'jp',
			'nl_NL' => 'nl',
			'pl_PL' => 'pl',
			'ru_RU' => 'ru',
			'es_MX' => 'mx',
			'id_ID' => 'id',
			'tr_TR' => 'tr',
			'uk' => 'ua',
			'cs_CZ' => 'cz',
			'pt_PT' => 'pt',
			'ro_RO' => 'ro',
			'hu_HU' => 'hu',
			'zh_CN' => 'cn',
			'zh_TW' => 'tw',
			'ko_KR' => 'kr',
		);
		$locale = $this->language->chosen_locale();

		if ( isset( $locale_flags[ $locale ] ) ) {
			$flag        = $locale_flags[ $locale ];
			$asset       = 'assets/img/flags/' . $flag . '.svg';
			$asset_path  = dirname( MARKETING_CONSOLE_FILE ) . '/' . $asset;
			$asset_mtime = is_file( $asset_path ) ? filemtime( $asset_path ) : false;
			$version     = false === $asset_mtime ? MARKETING_CONSOLE_VERSION : (string) $asset_mtime;
			$flag_url    = add_query_arg( 'ver', $version, plugin_dir_url( MARKETING_CONSOLE_FILE ) . $asset );
			printf(
				'<img src="%s" width="22" height="16" alt="" decoding="async">',
				esc_url( $flag_url )
			);
			return;
		}

		?>
		<svg viewBox="0 0 24 24" focusable="false"><circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" stroke-width="1.5"></circle><path d="M2.5 12h19M12 2.5c2.4 2.6 3.7 5.8 3.7 9.5S14.4 18.9 12 21.5M12 2.5C9.6 5.1 8.3 8.3 8.3 12s1.3 6.9 3.7 9.5" fill="none" stroke="currentColor" stroke-width="1.35"></path></svg>
		<?php
	}

	/**
	 * @return void
	 */
	private function render_report_cta(): void {
		?>
		<section class="mc-panel mc-panel--report">
			<h2><span class="mc-icon-ai" aria-hidden="true"><svg viewBox="0 0 24 24" focusable="false"><path d="M11 1.5h2V4h-2z"></path><circle cx="12" cy="1.6" r="1.4"></circle><rect x="4" y="4.5" width="16" height="13" rx="4"></rect><circle cx="9" cy="10.4" r="1.7" class="mc-icon-ai__eye"></circle><circle cx="15" cy="10.4" r="1.7" class="mc-icon-ai__eye"></circle><rect x="9" y="13.6" width="6" height="1.8" rx="0.9" class="mc-icon-ai__eye"></rect><rect x="1" y="8.5" width="2" height="5" rx="1"></rect><rect x="21" y="8.5" width="2" height="5" rx="1"></rect><rect x="8" y="19" width="8" height="2" rx="1"></rect></svg></span><?php echo esc_html__( 'Have AI evaluate the figures', 'speedlyst' ); ?></h2>
			<div class="mc-report-cta">
				<div>
					<p><?php echo esc_html__( 'Download a report with measured data about the server environment, memory, object cache, autoload data, cron, daily history, and PageSpeed Insights. It includes reference values and a ready-made question for the AI. Upload the file to a chat and let the strongest reasoning model determine what slows the site down most and what to fix first. Always pick the most capable model on offer, not the fastest one.', 'speedlyst' ); ?></p>
					<ul class="mc-report-cta__list">
					<li><?php echo esc_html__( 'All local measurements and the latest PSI result in one file', 'speedlyst' ); ?></li>
					<li><?php echo esc_html__( 'Reference values in each section so the model can distinguish good from poor', 'speedlyst' ); ?></li>
					<li><?php echo esc_html__( 'A ready-made question at the end, just upload the file to the chat', 'speedlyst' ); ?></li>
				</ul>
				<p class="mc-report-cta__note"><?php echo esc_html__( 'The file is created in your WordPress and downloaded directly to you. We do not send it anywhere.', 'speedlyst' ); ?></p>
				</div>
				<?php
				// Text-free illustration is shared by every locale.
				$art = array( 'report-ai-universal.png', 900, 675 );
				?>
				<span class="mc-report-cta__art mc-report-cta__art--universal" aria-hidden="true">
					<img src="<?php echo esc_url( plugin_dir_url( MARKETING_CONSOLE_FILE ) . 'assets/img/' . $art[0] ); ?>" width="<?php echo (int) $art[1]; ?>" height="<?php echo (int) $art[2]; ?>" alt="" loading="lazy" decoding="async">
				</span>
				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
					<input type="hidden" name="action" value="speedlyst_free_export_md">
					<?php wp_nonce_field( 'speedlyst_free_export_md' ); ?>
					<button class="button button-primary" type="submit"><svg class="mc-icon-download" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M10.2 2h3.6a1 1 0 0 1 1 1v7h3.1a.9.9 0 0 1 .68 1.48l-5.9 6.9a.9.9 0 0 1-1.36 0l-5.9-6.9A.9.9 0 0 1 6.1 10h3.1V3a1 1 0 0 1 1-1z"></path><path d="M2.6 14.2h3.2v4.2h12.4v-4.2h3.2v5.4A2.4 2.4 0 0 1 19 22H5A2.4 2.4 0 0 1 2.6 19.6v-5.4z"></path></svg><?php echo esc_html__( 'Download report', 'speedlyst' ); ?></button>
				</form>
			</div>
		</section>
		<?php
	}


	/**
	 * Trademark notice for the third party service we call.
	 *
	 * @return void
	 */
	private function render_disclaimer(): void {
		$consent = __( 'PageSpeed Insights measurements run only when you click the measurement button. We perform the measurement and Google receives your site address, never your content or visitor data.', 'speedlyst' );
		$marks   = __( 'The PageSpeed Insights name is used solely to identify the third-party service that the plugin calls for your site; the relevant trademarks belong to their owners and their use does not imply any partnership, sponsorship, or endorsement.', 'speedlyst' );
		?>
		<p class="mc-disclaimer">
			<?php echo esc_html( $consent . ' ' . $marks ); ?>
		</p>
		<?php
	}

	/**
	 * Product and legal links.
	 *
	 * @return void
	 */
	private function render_brandlinks(): void {
		?>
		<div class="mc-brandlinks">
			<a class="mc-brandlinks__link" href="<?php echo esc_url( 'https://speedlyst.org/' ); ?>" target="_blank" rel="noopener noreferrer">Speedlyst.org</a>
			<a class="mc-brandlinks__link" href="<?php echo esc_url( 'https://speedlyst.org/#faq' ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( __( 'Frequently asked questions', 'speedlyst' ) ); ?></a>
			<a class="mc-brandlinks__link" href="<?php echo esc_url( 'https://speedlyst.org/privacy/' ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( __( 'Privacy policy', 'speedlyst' ) ); ?></a>
			<a class="mc-brandlinks__link" href="<?php echo esc_url( 'https://speedlyst.org/terms/' ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( __( 'Terms of service', 'speedlyst' ) ); ?></a>
		</div>
		<?php
	}

	/**
	 * @return void
	 */
	private function render_notice(): void {
		if ( ! isset( $_GET['mc_free_notice'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only redirect marker.
			return;
		}

		$notice = sanitize_key( wp_unslash( $_GET['mc_free_notice'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only redirect marker.
		$text   = array(
			'psi_complete'  => array( 'success', __( 'The PSI request was completed.', 'speedlyst' ) ),
			'rescan_complete' => array( 'success', __( 'The local numbers were measured again.', 'speedlyst' ) ),
			'psi_quota_reached' => array( 'error', __( 'The daily measurement limit is used up; it resets at midnight.', 'speedlyst' ) ),
			'psi_busy'        => array( 'error', __( 'Another measurement is already running. Try again in a moment.', 'speedlyst' ) ),
			'storage_failed' => array( 'error', __( 'The settings could not be saved. Check the database status and try again.', 'speedlyst' ) ),
		);

		if ( isset( $text[ $notice ] ) ) {
			echo '<div class="notice notice-' . esc_attr( $text[ $notice ][0] ) . ' is-dismissible"><p>' . esc_html( $text[ $notice ][1] ) . '</p></div>';
		}
	}

	/**
	 * Green or amber verdict under a headline number, so the value means
	 * something even to someone who does not know the metric.
	 *
	 * @param string              $card Card key.
	 * @param array<string,mixed> $data Snapshot data.
	 * @param array<string,mixed> $cron Cron diagnostics.
	 * @return array<string,string>
	 */
	private function card_state( string $card, array $data, array $cron ): array {
		$reasons = array(
			'memory_limit' => __( 'PHP has no memory limit. This does not slow the site down, but a looping plugin can consume all server memory. A reasonable limit is 256 to 512 MB.', 'speedlyst' ),
			'memory_peak'  => __( 'The measured request used over 80 percent of the allowed memory, so little remains and a more demanding operation may fail.', 'speedlyst' ),
			'php'          => __( 'This PHP branch no longer receives security fixes from PHP developers, so this is a security risk, not only a performance issue. A newer version is often significantly faster too.', 'speedlyst' ),
			'autoload'     => __( 'Options loaded on every request exceeded 800 kB. They are most often remnants of uninstalled plugins.', 'speedlyst' ),
			'opcache'      => __( 'OPcache is disabled, so PHP recompiles the same files on every request. Your host must enable it.', 'speedlyst' ),
			'cron'         => __( 'The oldest scheduled task has been waiting for over an hour. WP-Cron runs when the site is visited, so it stalls on a low-traffic site.', 'speedlyst' ),
		);
		$reason = isset( $reasons[ $card ] ) ? $reasons[ $card ] : '';
		$ok    = array( 'tone' => 'good', 'label' => __( 'good', 'speedlyst' ), 'reason' => '' );
		$warn  = array( 'tone' => 'warn', 'label' => __( 'warning', 'speedlyst' ), 'reason' => $reason );
		$bad   = array( 'tone' => 'bad', 'label' => __( 'critical', 'speedlyst' ), 'reason' => $reason );
		$none  = array( 'tone' => 'none', 'label' => '', 'reason' => '' );

		if ( 'memory_limit' === $card ) {
			$field = $this->field( $data, 'memory_limit' );
			if ( 'ok' !== ( isset( $field['status'] ) ? $field['status'] : '' ) ) {
				return $none;
			}

			return '-1' === trim( (string) $field['value'] ) ? $warn : $ok;
		}

		if ( 'memory_peak' === $card ) {
			$peak  = $this->field( $data, 'memory_peak_mb' );
			$limit = $this->field( $data, 'memory_limit' );
			if ( 'ok' !== ( isset( $peak['status'] ) ? $peak['status'] : '' ) || 'ok' !== ( isset( $limit['status'] ) ? $limit['status'] : '' ) ) {
				return $none;
			}
			$bytes = $this->recommendations->memory_limit_bytes( (string) $limit['value'] );
			if ( null === $bytes || $bytes <= 0 ) {
				return $none;
			}

			$share = ( (float) $peak['value'] * 1048576 ) / $bytes;

			return $share >= 0.95 ? $bad : ( $share >= 0.8 ? $warn : $ok );
		}

		if ( 'php' === $card ) {
			$eol = $this->recommendations->php_eol_field( $this->field( $data, 'php_ver' ) );

			return 'ok' === $eol['status'] ? ( true === $eol['value'] ? $bad : $ok ) : $none;
		}

		if ( 'autoload' === $card ) {
			$field = $this->field( $data, 'autoload_bytes' );
			if ( 'ok' !== ( isset( $field['status'] ) ? $field['status'] : '' ) || ! is_int( $field['value'] ) ) {
				return $none;
			}

			return $field['value'] > 1500000 ? $bad : ( $field['value'] > 800000 ? $warn : $ok );
		}

		if ( 'opcache' === $card ) {
			$field = $this->field( $data, 'opcache_enabled' );

			return 'ok' === ( isset( $field['status'] ) ? $field['status'] : '' ) ? ( true === $field['value'] ? $ok : $warn ) : $none;
		}

		if ( 'cron' === $card ) {
			if ( 'ok' !== ( isset( $cron['status'] ) ? $cron['status'] : '' ) || ! isset( $cron['oldest_overdue_seconds'] ) ) {
				return $none;
			}
			$oldest = $cron['oldest_overdue_seconds'];

			if ( ! is_int( $oldest ) ) {
				return $ok;
			}

			return $oldest >= 86400 ? $bad : ( $oldest >= 3600 ? $warn : $ok );
		}

		return $none;
	}

	/**
	 * @param array<string,mixed> $field Measured field.
	 * @return bool
	 */
	private function is_true( array $field ): bool {
		return 'ok' === ( isset( $field['status'] ) ? $field['status'] : '' ) && true === $field['value'];
	}

	/**
	 * @param array<string,mixed> $field Measured field.
	 * @return bool
	 */
	private function is_false( array $field ): bool {
		return 'ok' === ( isset( $field['status'] ) ? $field['status'] : '' ) && false === $field['value'];
	}

	/**
	 * @param array<string,mixed> $data Snapshot data.
	 * @return bool
	 */
	private function cache_inactive( array $data ): bool {
		$field = $this->field( $data, 'object_cache' );

		return 'ok' === ( isset( $field['status'] ) ? $field['status'] : '' ) && is_array( $field['value'] ) && isset( $field['value']['external'] ) && false === $field['value']['external'];
	}

	/**
	 * Priority of a recommendation. Security and outage risk outrank tuning.
	 *
	 * @param array<string,string> $recommendation One recommendation.
	 * @return array<string,string>
	 */
	private function recommendation_priority( array $recommendation ): array {
		$high = array(
			__( 'Upgrade PHP', 'speedlyst' ),
			__( 'Reduce peak memory usage', 'speedlyst' ),
			__( 'Speed up main content loading', 'speedlyst' ),
			__( 'Increase PHP memory_limit', 'speedlyst' ),
		);
		$low  = array(
			__( 'Reduce autoload data', 'speedlyst' ),
			__( 'Investigate slow main content loading', 'speedlyst' ),
		);
		$title = isset( $recommendation['title'] ) ? (string) $recommendation['title'] : '';

		if ( in_array( $title, $high, true ) ) {
			return array( 'level' => 'high', 'label' => __( 'HIGH', 'speedlyst' ) );
		}

		if ( in_array( $title, $low, true ) ) {
			return array( 'level' => 'low', 'label' => __( 'LOW', 'speedlyst' ) );
		}

		return array( 'level' => 'medium', 'label' => __( 'MEDIUM', 'speedlyst' ) );
	}

	/**
	 * @param array<string,mixed> $data Snapshot data.
	 * @param string              $key  Field key.
	 * @return array<string,mixed>
	 */
	private function field( array $data, string $key ): array {
		return isset( $data[ $key ] ) && is_array( $data[ $key ] ) ? $data[ $key ] : array( 'value' => null, 'status' => 'not_sampled' );
	}

	/**
	 * Status-aware value formatter. Non-ok values are never coerced to zero.
	 *
	 * @param array<string,mixed> $field Normalized measured field.
	 * @param string              $type  Display type.
	 * @return string
	 */
	private function format_field( array $field, string $type ): string {
		if ( 'ok' !== ( isset( $field['status'] ) ? $field['status'] : '' ) || ! array_key_exists( 'value', $field ) || null === $field['value'] ) {
			return __( 'not measured', 'speedlyst' );
		}

		$value = $field['value'];
		if ( 'object_cache' === $type ) {
			if ( ! is_array( $value ) || ! isset( $value['external'], $value['dropin_present'], $value['backend'] ) || ! is_bool( $value['external'] ) || ! is_bool( $value['dropin_present'] ) || ! is_string( $value['backend'] ) ) {
				return __( 'not measured', 'speedlyst' );
			}

			// Každý údaj na vlastní řádek (buňka má white-space: pre-line).
			return sprintf(
				/* translators: 1: active state, 2: drop-in state, 3: backend. */
				__( "active: %1\$s\ndrop-in: %2\$s\nbackend: %3\$s", 'speedlyst' ),
				$value['external'] ? __( 'yes', 'speedlyst' ) : __( 'no', 'speedlyst' ),
				$value['dropin_present'] ? __( 'yes', 'speedlyst' ) : __( 'no', 'speedlyst' ),
				'unknown' === $value['backend'] ? __( 'unknown', 'speedlyst' ) : $value['backend']
			);
		}

		if ( in_array( $type, array( 'boolean', 'enabled' ), true ) && is_bool( $value ) ) {
			return 'enabled' === $type
				? ( $value ? __( 'enabled', 'speedlyst' ) : __( 'disabled', 'speedlyst' ) )
				: ( $value ? __( 'yes', 'speedlyst' ) : __( 'no', 'speedlyst' ) );
		}

		if ( 'memory_limit' === $type && ( is_string( $value ) || is_int( $value ) ) ) {
			// PHP zapisuje neomezenou pamet jako -1; uzivateli to nic nerekne.
			return '-1' === trim( (string) $value ) ? __( 'unlimited', 'speedlyst' ) : (string) $value;
		}

		if ( 'integer' === $type && is_int( $value ) ) {
			return number_format_i18n( $value );
		}

		if ( 'bytes' === $type && ( is_int( $value ) || is_float( $value ) ) && $value >= 0 ) {
			return $this->format_bytes( (float) $value );
		}

		if ( in_array( $type, array( 'megabytes', 'milliseconds', 'score', 'decimal' ), true ) && ( is_int( $value ) || is_float( $value ) ) ) {
			$number = number_format_i18n( $value, 'decimal' === $type ? 3 : 0 );
			$unit   = array(
				'megabytes'   => ' MB',
				'milliseconds' => ' ms',
				'score'        => ' / 100',
				'decimal'      => '',
			);

			return $number . $unit[ $type ];
		}

		if ( 'plain' === $type && ( is_string( $value ) || is_int( $value ) || is_float( $value ) ) ) {
			return (string) $value;
		}

		return __( 'not measured', 'speedlyst' );
	}

	/**
	 * @param string $label Row label.
	 * @param string $value Escaped later.
	 * @param bool   $warning Whether the metric needs attention.
	 * @return void
	 */
	private function render_row( string $label, string $value, bool $warning = false, string $reason = '' ): void {
		?>
		<tr class="<?php echo esc_attr( $this->value_class( $value ) . ( $warning ? ' mc-row--warning' : '' ) ); ?>">
			<th scope="row"><?php echo esc_html( $label ); ?></th>
			<td>
				<?php echo nl2br( esc_html( $value ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- nl2br over escaped text. ?>
				<?php if ( $warning ) : ?>
					<span class="mc-warning-badge mc-hint" tabindex="0" role="button" aria-label="<?php echo esc_attr( '' !== $reason ? $reason : __( 'Warning', 'speedlyst' ) ); ?>">
						<?php echo esc_html__( 'Warning', 'speedlyst' ); ?>
						<?php if ( '' !== $reason ) : ?>
							<span class="mc-hint__bubble"><?php echo esc_html( $reason ); ?></span>
						<?php endif; ?>
					</span>
				<?php endif; ?>
			</td>
		</tr>
		<?php
	}

	/**
	 * A compact CSS state for missing or unavailable measured values.
	 *
	 * @param string $value Rendered measurement value.
	 * @return string
	 */
	private function value_class( string $value ): string {
		return __( 'not measured', 'speedlyst' ) === $value ? ' mc-is-unmeasured' : '';
	}

	/**
	 * True when a cron diagnostic reports a positive overdue value.
	 *
	 * @param array<string,mixed> $diagnostic Diagnostic value map.
	 * @param string              $key        Numeric diagnostic key.
	 * @return bool
	 */
	private function is_overdue( array $diagnostic, string $key ): bool {
		return isset( $diagnostic['status'], $diagnostic[ $key ] ) && 'ok' === $diagnostic['status'] && ( is_int( $diagnostic[ $key ] ) || is_float( $diagnostic[ $key ] ) ) && $diagnostic[ $key ] > 0;
	}

	/**
	 * @param string $nonce_action Nonce action.
	 * @return void
	 */
	private function authorize_post( string $nonce_action ): void {
		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';

		if ( 'POST' !== $method ) {
			wp_die( esc_html__( 'This action requires POST.', 'speedlyst' ), '', array( 'response' => 405 ) );
		}

		if ( ! current_user_can( self::CAPABILITY ) ) {
			wp_die( esc_html__( 'You are not allowed to perform this action.', 'speedlyst' ), '', array( 'response' => 403 ) );
		}

		check_admin_referer( $nonce_action );
	}

	/**
	 * @param string $notice Allowlisted notice code.
	 * @return void
	 */
	private function redirect( string $notice ): void {
		$url = add_query_arg(
			array(
				'page'           => self::PAGE_SLUG,
				'mc_free_notice' => sanitize_key( $notice ),
			),
			admin_url( 'admin.php' )
		);

		wp_safe_redirect( $url );
		exit;
	}
}
