<?php
/**
 * Free-only local diagnostics that remain on this site.
 *
 * These metrics are deliberately kept outside the data returned by the standard
 * speed collectors. They live only in the local dashboard and local history, so
 * adding a diagnostic here does not add it to any externally sent data.
 *
 * Every reading is honest: a value that cannot be measured on the host is
 * reported as `unsupported`, never guessed. No option names, values, cron args,
 * SQL, URLs or other PII are ever stored.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Modules\Speed;

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

/**
 * Local, request-free (plus one shutdown sampler) diagnostics for the free tier.
 */
final class LocalDiagnostics {

	public const OPTION_PROFILE = 'speedlyst_free_request_profile';

	/**
	 * Grace window before a due cron job counts as overdue.
	 */
	private const CRON_GRACE_SECONDS = 120;

	/**
	 * Minimum spacing between stored request-profile samples (one DB write).
	 */
	private const PROFILE_THROTTLE_SECONDS = 300;

	/**
	 * Register the shutdown request-profile sampler on real admin page loads.
	 *
	 * Explicitly NOT on the publicly reachable admin-ajax.php (is_admin() is
	 * true there even for anonymous visitors), the front end or cron. The
	 * capability and throttle are enforced again at capture time.
	 *
	 * @return void
	 */
	public function register(): void {
		if ( ! function_exists( 'is_admin' ) || ! is_admin() ) {
			return;
		}
		if ( ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) || ( defined( 'DOING_CRON' ) && constant( 'DOING_CRON' ) ) ) {
			return;
		}

		add_action( 'shutdown', array( $this, 'capture_request_profile' ), PHP_INT_MAX );
	}

	/**
	 * Snapshot the current admin request at shutdown: real end-of-request peak
	 * memory, DB query count and PHP generation time.
	 *
	 * Guarded so an unauthenticated or ajax request can never force a DB write,
	 * and throttled to at most one stored sample per window (no write amplification).
	 *
	 * @return void
	 */
	public function capture_request_profile(): void {
		if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
			return;
		}
		if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$existing = $this->request_profile();
		if ( isset( $existing['captured_ts'] ) && is_int( $existing['captured_ts'] ) && ( time() - $existing['captured_ts'] ) < self::PROFILE_THROTTLE_SECONDS ) {
			return;
		}

		global $wpdb;

		$now     = microtime( true );
		$started = isset( $_SERVER['REQUEST_TIME_FLOAT'] ) && is_numeric( $_SERVER['REQUEST_TIME_FLOAT'] ) ? (float) $_SERVER['REQUEST_TIME_FLOAT'] : null;

		// Honest metric: only a finite, non-future start yields a value; a bad
		// or negative sample is `null` (unknown), never a fabricated 0 ms.
		$generation_ms = null;
		if ( null !== $started && is_finite( $started ) && $started > 0 && $started <= $now ) {
			$generation_ms = round( ( $now - $started ) * 1000, 1 );
		}

		$profile = array(
			'context'        => 'admin',
			'observed_at'    => gmdate( 'Y-m-d\TH:i:s\Z' ),
			'captured_ts'    => time(),
			'generation_ms'  => $generation_ms,
			'db_query_count' => ( isset( $wpdb ) && is_object( $wpdb ) && isset( $wpdb->num_queries ) && is_int( $wpdb->num_queries ) ) ? $wpdb->num_queries : null,
			'peak_memory_mb' => $this->safe_peak_mb(),
		);

		if ( function_exists( 'update_option' ) ) {
			update_option( self::OPTION_PROFILE, $profile, false );
		}
	}

	/**
	 * Last stored admin request profile, or an empty array.
	 *
	 * @return array<string,mixed>
	 */
	public function request_profile(): array {
		$stored = function_exists( 'get_option' ) ? get_option( self::OPTION_PROFILE, array() ) : array();

		return is_array( $stored ) ? $stored : array();
	}

	/**
	 * Core-recognized autoload values with a WordPress 5.8 compatible fallback.
	 *
	 * @return string[]
	 */
	private function autoload_values(): array {
		$callback = 'wp_autoload_values_to_autoload';
		$values   = function_exists( $callback ) ? $callback() : array( 'yes', 'on', 'auto', 'auto-on' );

		return is_array( $values ) && ! empty( $values ) ? $values : array( 'yes' );
	}

	/**
	 * Autoloaded options: how many rows load on every request and their total
	 * serialized byte size. High autoload bloat slows every single request.
	 *
	 * @return array<string,mixed>
	 */
	public function autoloaded_options(): array {
		global $wpdb;

		if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->options ) ) {
			return array( 'count' => null, 'bytes' => null, 'status' => 'unsupported' );
		}

		// WP 6.6+ replaced the 'yes'/'no' autoload flag with a set of values
		// ('on', 'auto', 'auto-on', ...). Use WordPress's own enabled-values list
		// when present, else the known autoloaded states, so the count is correct
		// on both old and new cores.
		$values = $this->autoload_values();

		// Parameterized aggregate; never selects option_name or option_value.
		$placeholders = implode( ', ', array_fill( 0, count( $values ), '%s' ) );
		$sql          = "SELECT COUNT(*) AS c, COALESCE(SUM(LENGTH(option_value)), 0) AS b FROM {$wpdb->options} WHERE autoload IN ( {$placeholders} )";
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Read-only live diagnostic; identifiers are core-owned and every value uses a generated placeholder.
		$row          = $wpdb->get_row( $wpdb->prepare( $sql, $values ), ARRAY_A );

		if ( ! is_array( $row ) || ! isset( $row['c'], $row['b'] ) ) {
			return array( 'count' => null, 'bytes' => null, 'status' => 'unsupported' );
		}

		return array(
			'count'  => (int) $row['c'],
			'bytes'  => (int) $row['b'],
			'status' => 'ok',
		);
	}

	/**
	 * The largest autoloaded options by serialized size.
	 *
	 * Display-only helper for the local free dashboard: the names are read live
	 * for the current admin and are never stored in an option or sent externally.
	 * Values are never selected, only their byte length.
	 *
	 * @param int $limit How many rows to return (1 to 20).
	 * @return array<string,mixed> rows and status.
	 */
	public function autoloaded_options_top( int $limit = 8 ): array {
		global $wpdb;

		$limit = max( 1, min( 20, $limit ) );

		if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->options ) ) {
			return array( 'rows' => array(), 'status' => 'unsupported' );
		}

		$values = $this->autoload_values();

		$placeholders = implode( ', ', array_fill( 0, count( $values ), '%s' ) );
		$sql          = "SELECT option_name AS n, LENGTH(option_value) AS b FROM {$wpdb->options} WHERE autoload IN ( {$placeholders} ) ORDER BY b DESC LIMIT %d";
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Read-only live diagnostic; identifiers are core-owned and every value uses a generated placeholder.
		$rows         = $wpdb->get_results( $wpdb->prepare( $sql, array_merge( $values, array( $limit ) ) ), ARRAY_A );

		if ( ! is_array( $rows ) ) {
			return array( 'rows' => array(), 'status' => 'unsupported' );
		}

		$clean = array();
		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) || ! isset( $row['n'], $row['b'] ) ) {
				continue;
			}

			$clean[] = array(
				'name'  => substr( (string) $row['n'], 0, 96 ),
				'bytes' => (int) $row['b'],
			);
		}

		return array( 'rows' => $clean, 'status' => 'ok' );
	}

	/**
	 * Cron backlog: how many scheduled jobs are overdue and how old the oldest
	 * overdue job is. A growing backlog explains a slow or unreliable site.
	 *
	 * @return array<string,mixed>
	 */
	public function cron_backlog(): array {
		if ( ! function_exists( '_get_cron_array' ) ) {
			return array( 'overdue_count' => null, 'oldest_overdue_seconds' => null, 'status' => 'unsupported' );
		}

		$crons = _get_cron_array();
		if ( ! is_array( $crons ) ) {
			return array( 'overdue_count' => null, 'oldest_overdue_seconds' => null, 'status' => 'unsupported' );
		}

		$now       = time();
		$threshold = $now - self::CRON_GRACE_SECONDS;
		$overdue   = 0;
		$oldest    = null;

		foreach ( $crons as $timestamp => $hooks ) {
			if ( ! is_int( $timestamp ) || $timestamp > $threshold ) {
				continue;
			}

			// Structure: timestamp => hook => signature => event. Count scheduled
			// events (per signature), not unique hook names.
			if ( is_array( $hooks ) ) {
				foreach ( $hooks as $events ) {
					$overdue += is_array( $events ) ? count( $events ) : 1;
				}
			} else {
				++$overdue;
			}

			$age = $now - $timestamp;
			if ( null === $oldest || $age > $oldest ) {
				$oldest = $age;
			}
		}

		return array(
			'overdue_count'          => $overdue,
			'oldest_overdue_seconds' => $oldest,
			'status'                 => 'ok',
		);
	}

	/**
	 * @return float|null Peak MB, or null when unavailable on this host.
	 */
	private function safe_peak_mb() {
		if ( ! function_exists( 'memory_get_peak_usage' ) ) {
			return null;
		}

		try {
			return round( memory_get_peak_usage( true ) / 1048576, 1 );
		} catch ( \Throwable $e ) {
			return null;
		}
	}
}
