<?php
/**
 * Bounded, blog-local Speed snapshot history.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Modules\Speed;

use MarketingConsole\Core\OptionLock;

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

/**
 * Captures at most one local snapshot per calendar day.
 */
final class LocalHistory {

	public const OPTION_HISTORY = 'speedlyst_free_speed_history';
	public const OPTION_LOCK    = 'speedlyst_free_speed_history_lock';
	public const CRON_HOOK      = 'speedlyst_free_daily_snapshot';
	public const MAX_ENTRIES    = 90;

	private const DATA_FIELDS = array(
		'php_ver',
		'wp_ver',
		'plugins_active',
		'active_theme',
		'wp_debug',
		'opcache_enabled',
		'memory_limit',
		'wp_memory_limit',
		'wp_max_memory_limit',
		'memory_peak_mb',
		'object_cache',
		'autoload_bytes',
		'autoload_count',
	);

	private SpeedModule $speed;

	/**
	 * Local-only diagnostics source. Optional so existing callers and tests keep
	 * working; without it the local-only fields stay `unsupported`.
	 *
	 * @var LocalDiagnostics|null
	 */
	private ?LocalDiagnostics $diagnostics;

	/**
	 * @param SpeedModule           $speed       Existing read-only Speed collectors.
	 * @param LocalDiagnostics|null $diagnostics Free-only local diagnostics.
	 */
	public function __construct( SpeedModule $speed, ?LocalDiagnostics $diagnostics = null ) {
		$this->speed       = $speed;
		$this->diagnostics = $diagnostics;
	}

	/**
	 * Register local-only collection hooks.
	 *
	 * @return void
	 */
	public function register(): void {
		// Aktivační a deaktivační hooky registruje bootstrap na top-levelu;
		// registrace uvnitř plugins_loaded by při aktivaci nikdy neproběhla.
		add_action( self::CRON_HOOK, array( $this, 'capture_daily' ) );
		add_action( 'admin_init', array( $this, 'capture_on_admin_load' ) );

		$this->ensure_scheduled();
	}

	/**
	 * Schedule an opportunistic daily WP-Cron event.
	 *
	 * @return void
	 */
	public static function activate(): void {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + 300, 'daily', self::CRON_HOOK );
		}
	}

	/**
	 * Remove the event when the plugin is inactive; local data is retained.
	 *
	 * @return void
	 */
	public static function deactivate(): void {
		wp_clear_scheduled_hook( self::CRON_HOOK );
	}

	/**
	 * Admin fallback for sites where WP-Cron has not run.
	 *
	 * @return void
	 */
	public function capture_on_admin_load(): void {
		if ( current_user_can( 'manage_options' ) ) {
			$this->capture_daily();
		}
	}

	/**
	 * Capture today's snapshot only when it is not already stored.
	 *
	 * @return array<string,mixed>
	 */
	public function capture_daily(): array {
		$date = $this->local_date();

		// Příkazová řádka běží na jiném PHP a s jinou pamětí než web. Kdyby
		// takový běh zapsal denní snapshot, historie by tvrdila něco, co
		// návštěvník webu nikdy nezažil.
		if ( defined( 'WP_CLI' ) && constant( 'WP_CLI' ) ) {
			foreach ( $this->history() as $entry ) {
				if ( $date === $entry['date'] ) {
					return $entry;
				}
			}

			return array( 'date' => $date, 'observed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'execution_context' => 'admin', 'data' => array(), 'storage_status' => 'skipped_cli' );
		}

		foreach ( $this->history() as $entry ) {
			if ( $date === $entry['date'] ) {
				return $entry;
			}
		}

		return $this->collect_and_store( $date );
	}

	/**
	 * Measure again right now and replace today's entry.
	 *
	 * The daily snapshot answers "what did the site look like today", which is
	 * the wrong question after someone changes a setting and wants to see the
	 * effect. This is the only path that overwrites an already stored day.
	 *
	 * @return array<string,mixed>
	 */
	public function capture_now(): array {
		$date = $this->local_date();

		// Stejný důvod jako u denního snímku: hodnoty z příkazové řádky nepatří
		// do historie, kterou uživatel čte jako obraz webu.
		if ( defined( 'WP_CLI' ) && constant( 'WP_CLI' ) ) {
			return array( 'date' => $date, 'observed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'execution_context' => 'admin', 'data' => array(), 'storage_status' => 'skipped_cli' );
		}

		return $this->collect_and_store( $date );
	}

	/**
	 * @param string $date Local calendar day of the snapshot.
	 * @return array<string,mixed>
	 */
	private function collect_and_store( string $date ): array {
		$report   = $this->speed->collect();
		$snapshot = array(
			'date'              => $date,
			'observed_at'       => gmdate( 'Y-m-d\TH:i:s\Z' ),
			'execution_context' => $this->speed->execution_context(),
			'data'              => $this->with_local_diagnostics( isset( $report['data'] ) && is_array( $report['data'] ) ? $report['data'] : array() ),
		);

		$snapshot['storage_status'] = $this->store( $snapshot ) ? 'ok' : 'error';

		return $snapshot;
	}

	/**
	 * Store one snapshot, replace a duplicate date and drop oldest overflow.
	 *
	 * Public for deterministic bounded-storage tests.
	 *
	 * @param array<string,mixed> $snapshot Snapshot to store.
	 * @return bool
	 */
	public function store( array $snapshot ): bool {
		$snapshot = $this->valid_snapshot( $snapshot );

		if ( empty( $snapshot ) ) {
			return false;
		}

		// Zámek kolem read-modify-write: souběh cronu, admin_init a rescanu by
		// jinak mohl ztratit novější snapshot (poslední zápis vyhrává nad
		// seznamem načteným před cizím zápisem). Krátké čekání pokryje běžnou
		// kolizi; když zámek nepustí ani pak, vrací se poctivé selhání, ne
		// tichý zápis bez ochrany.
		$token = $this->acquire_store_lock();

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

		try {
			$history = array();
			foreach ( $this->history() as $entry ) {
				if ( $entry['date'] !== $snapshot['date'] ) {
					$history[] = $entry;
				}
			}

			$history[] = $snapshot;
			usort(
				$history,
				static function ( array $left, array $right ): int {
					return strcmp( $left['date'], $right['date'] );
				}
			);
			$history = array_slice( $history, -self::MAX_ENTRIES );

			$updated = update_option( self::OPTION_HISTORY, $history, false );

			return $updated || $history === get_option( self::OPTION_HISTORY, array() );
		} finally {
			OptionLock::release( self::OPTION_LOCK, $token );
		}
	}

	/**
	 * @return string Owned token, or an empty string after the short wait.
	 */
	private function acquire_store_lock(): string {
		for ( $attempt = 0; $attempt < 3; $attempt++ ) {
			$token = OptionLock::acquire( self::OPTION_LOCK, 5 );

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

			usleep( 100000 );
		}

		return '';
	}

	/**
	 * Read only valid snapshots and enforce the cap even after option tampering.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	public function history(): array {
		$stored  = get_option( self::OPTION_HISTORY, array() );
		$history = array();

		if ( ! is_array( $stored ) ) {
			return $history;
		}

		foreach ( $stored as $snapshot ) {
			if ( ! is_array( $snapshot ) ) {
				continue;
			}

			$valid = $this->valid_snapshot( $snapshot );
			if ( ! empty( $valid ) ) {
				$history[ $valid['date'] ] = $valid;
			}
		}

		ksort( $history );

		return array_slice( array_values( $history ), -self::MAX_ENTRIES );
	}

	/**
	 * Add the local-only diagnostics fields to a snapshot.
	 *
	 * These fields live only in this local option and are not sent externally.
	 * Without a diagnostics source, or when the host cannot measure them, they
	 * stay `unsupported` and are never guessed.
	 *
	 * @param array<string,mixed> $data Collector data.
	 * @return array<string,mixed>
	 */
	private function with_local_diagnostics( array $data ): array {
		$unsupported = array( 'value' => null, 'status' => 'unsupported' );

		if ( null === $this->diagnostics ) {
			$data['autoload_bytes'] = $unsupported;
			$data['autoload_count'] = $unsupported;

			return $data;
		}

		$autoload               = $this->diagnostics->autoloaded_options();
		$measured               = isset( $autoload['status'] ) && 'ok' === $autoload['status'];
		$data['autoload_bytes'] = $measured && isset( $autoload['bytes'] ) && is_int( $autoload['bytes'] )
			? array( 'value' => $autoload['bytes'], 'status' => 'ok' )
			: $unsupported;
		$data['autoload_count'] = $measured && isset( $autoload['count'] ) && is_int( $autoload['count'] )
			? array( 'value' => $autoload['count'], 'status' => 'ok' )
			: $unsupported;

		return $data;
	}

	/**
	 * @return void
	 */
	private function ensure_scheduled(): void {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + 300, 'daily', self::CRON_HOOK );
		}
	}

	/**
	 * @return string
	 */
	private function local_date(): string {
		return wp_date( 'Y-m-d' );
	}

	/**
	 * Keep only the bounded local schema used by the dashboard.
	 *
	 * @param array<string,mixed> $snapshot Candidate snapshot.
	 * @return array<string,mixed>
	 */
	private function valid_snapshot( array $snapshot ): array {
		$date        = isset( $snapshot['date'] ) && is_string( $snapshot['date'] ) ? $snapshot['date'] : '';
		$observed_at = isset( $snapshot['observed_at'] ) && is_string( $snapshot['observed_at'] ) ? $snapshot['observed_at'] : '';
		$context     = isset( $snapshot['execution_context'] ) && is_string( $snapshot['execution_context'] ) ? $snapshot['execution_context'] : '';

		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/D', $date ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/D', $observed_at ) ) {
			return array();
		}

		if ( ! in_array( $context, array( 'wp_cron', 'admin', 'frontend' ), true ) || ! isset( $snapshot['data'] ) || ! is_array( $snapshot['data'] ) ) {
			return array();
		}

		return array(
			'date'              => $date,
			'observed_at'       => $observed_at,
			'execution_context' => $context,
			'data'              => $this->valid_data( $snapshot['data'] ),
		);
	}

	/**
	 * Strip everything outside the fixed local snapshot schema.
	 *
	 * @param array<string,mixed> $data Candidate collector data.
	 * @return array<string,array<string,mixed>>
	 */
	private function valid_data( array $data ): array {
		$valid = array();

		foreach ( self::DATA_FIELDS as $key ) {
			// A key that is missing entirely (older snapshot, field added later)
			// was never measured; it is not a measurement error.
			if ( ! isset( $data[ $key ] ) || ! is_array( $data[ $key ] ) ) {
				$valid[ $key ] = array( 'value' => null, 'status' => 'not_sampled' );
				continue;
			}

			$valid[ $key ] = $this->valid_field( $key, $data[ $key ] );
		}

		return $valid;
	}

	/**
	 * Preserve measured false/zero, but never manufacture a value for non-ok status.
	 *
	 * @param string              $key   Fixed field key.
	 * @param array<string,mixed> $field Candidate field.
	 * @return array<string,mixed>
	 */
	private function valid_field( string $key, array $field ): array {
		$statuses = array( 'ok', 'unsupported', 'disabled', 'error', 'not_sampled' );
		$status   = isset( $field['status'] ) && is_string( $field['status'] ) && in_array( $field['status'], $statuses, true ) ? $field['status'] : 'error';

		if ( 'ok' !== $status || ! array_key_exists( 'value', $field ) ) {
			return array( 'value' => null, 'status' => $status );
		}

		$value = $field['value'];
		if ( in_array( $key, array( 'php_ver', 'wp_ver', 'active_theme' ), true ) && is_string( $value ) && '' !== $value ) {
			return array( 'value' => substr( $value, 0, 128 ), 'status' => 'ok' );
		}

		if ( in_array( $key, array( 'memory_limit', 'wp_memory_limit', 'wp_max_memory_limit' ), true ) && ( is_string( $value ) || is_int( $value ) ) ) {
			return array( 'value' => substr( (string) $value, 0, 128 ), 'status' => 'ok' );
		}

		if ( in_array( $key, array( 'plugins_active', 'autoload_bytes', 'autoload_count' ), true ) && is_int( $value ) && $value >= 0 ) {
			return array( 'value' => $value, 'status' => 'ok' );
		}

		if ( in_array( $key, array( 'wp_debug', 'opcache_enabled' ), true ) && is_bool( $value ) ) {
			return array( 'value' => $value, 'status' => 'ok' );
		}

		if ( 'memory_peak_mb' === $key && ( is_int( $value ) || is_float( $value ) ) && $value >= 0 ) {
			return array( 'value' => $value, 'status' => 'ok' );
		}

		if ( 'object_cache' === $key && is_array( $value ) && isset( $value['external'], $value['dropin_present'], $value['backend'] ) && is_bool( $value['external'] ) && is_bool( $value['dropin_present'] ) && in_array( $value['backend'], array( 'redis', 'memcached', 'unknown' ), true ) ) {
			return array(
				'value'  => array(
					'external'       => $value['external'],
					'dropin_present' => $value['dropin_present'],
					'backend'        => $value['backend'],
				),
				'status' => 'ok',
			);
		}

		return array( 'value' => null, 'status' => 'error' );
	}
}
