<?php
/**
 * Engine A: deterministic server environment checks (FACT tier).
 *
 * Distinguishes the WP hard minimums from the auto-update-offer floor:
 *  - MySQL 5.5.5 / MariaDB 10.4  = hard minimum (below: RED).
 *  - MySQL 8.0   / MariaDB 10.6  = auto-update-offer floor for WP 7.0 (below: AMBER "not auto-offered").
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Server_Check
 */
class UPilot_Server_Check {

	/**
	 * Fallbacks ONLY. The live values are read from WordPress itself:
	 *  - $required_php_version / $required_mysql_version (wp-includes/version.php)
	 *    are the hard minimums to RUN the installed WordPress; falling below them
	 *    makes core fatal in wp_check_php_mysql_versions() (wp-includes/load.php).
	 *  - The requirements of the update actually being OFFERED come from
	 *    api.wordpress.org, in the update_core transient ($offer->php_version,
	 *    $offer->mysql_version). That is what core compares against in
	 *    list_core_update() (wp-admin/update-core.php) to decide whether the
	 *    update can be installed.
	 * We never invent our own floors.
	 */
	const FALLBACK_PHP_MIN   = '7.4';
	const FALLBACK_MYSQL_MIN = '5.5.5';

	/**
	 * PHP branches and the date each one stops receiving security fixes.
	 *
	 * MAINTENANCE, TIME-SENSITIVE. Two things rot here:
	 *  1. This table must gain a row for each new PHP branch (8.6, 9.0, ...) and
	 *     be corrected if the PHP project moves a date.
	 *  2. MARKETING COPY EXPIRES WITH IT. The launch positioning leans on PHP 8.2
	 *     losing security support on 2026-12-31. After that date, that hook is
	 *     dead: the readme, the store listing, and any campaign copy must be
	 *     rewritten around the next branch to age out (8.3, on 2027-12-31).
	 *     Anything still saying "PHP 8.2 loses support this year" in 2027 is
	 *     simply wrong, and looks it.
	 *
	 * @var array<string,string>
	 */
	const PHP_EOL = array(
		'7.4' => '2022-11-28',
		'8.0' => '2023-11-26',
		'8.1' => '2025-12-31',
		'8.2' => '2026-12-31',
		'8.3' => '2027-12-31',
		'8.4' => '2028-12-31',
		'8.5' => '2029-12-31',
	);

	/**
	 * The hard minimums to RUN the installed WordPress, straight from core.
	 *
	 * @return array{php:string,mysql:string}
	 */
	private function core_minimums() {
		global $required_php_version, $required_mysql_version;
		return array(
			'php'   => ! empty( $required_php_version ) ? (string) $required_php_version : self::FALLBACK_PHP_MIN,
			'mysql' => ! empty( $required_mysql_version ) ? (string) $required_mysql_version : self::FALLBACK_MYSQL_MIN,
		);
	}

	/**
	 * The requirements of the core update WordPress is actually being offered,
	 * as reported by api.wordpress.org. This — not any floor we invent — is what
	 * core checks in list_core_update() before allowing the update.
	 *
	 * @return array|null {version, php, mysql} or null when no offer is cached.
	 */
	private function offered_update() {
		$updates = get_site_transient( 'update_core' );
		if ( ! isset( $updates->updates ) || ! is_array( $updates->updates ) ) {
			return null;
		}
		$best = null;
		foreach ( $updates->updates as $offer ) {
			if ( ! isset( $offer->response ) || ! in_array( $offer->response, array( 'upgrade', 'latest' ), true ) ) {
				continue;
			}
			// Prefer a real pending upgrade over "you are on the latest".
			if ( null === $best || 'upgrade' === $offer->response ) {
				$best = $offer;
			}
		}
		if ( ! $best ) {
			return null;
		}
		return array(
			'version' => isset( $best->version ) ? (string) $best->version : '',
			'php'     => ! empty( $best->php_version ) ? (string) $best->php_version : '',
			'mysql'   => ! empty( $best->mysql_version ) ? (string) $best->mysql_version : '',
		);
	}

	/**
	 * Run all server checks.
	 *
	 * @param string $target_php Target PHP version (e.g. '8.4').
	 * @return array[] Check items: {id, label, status pass|warn|fail, tier, detail}.
	 */
	public function run( $target_php ) {
		$items = array();

		$items[] = $this->check_php( $target_php );
		$items[] = $this->check_php_eol();
		$items[] = $this->check_database();
		$items[] = $this->check_memory();
		$items   = array_merge( $items, $this->check_extensions() );
		$items[] = $this->check_https();

		return $items;
	}

	/**
	 * Current PHP vs the WP floor and the chosen target.
	 *
	 * @param string $target_php Target PHP version.
	 * @return array
	 */
	private function check_php( $target_php ) {
		$current = PHP_VERSION;
		$minimum = $this->core_minimums();
		$offer   = $this->offered_update();

		// (1) Below the minimum required to run the WordPress that is installed.
		if ( version_compare( $current, $minimum['php'], '<' ) ) {
			return $this->item(
				'php_version',
				__( 'PHP version', 'upgrade-pilot' ),
				'fail',
				sprintf(
					/* translators: 1: current PHP version, 2: WordPress version, 3: minimum PHP version. */
					__( 'This server runs PHP %1$s, but WordPress %2$s requires at least PHP %3$s. WordPress reports this as a fatal requirement.', 'upgrade-pilot' ),
					$current,
					get_bloginfo( 'version' ),
					$minimum['php']
				)
			);
		}

		// (2) Below the PHP required by the core update WordPress is being offered.
		//     This is the real gate: core compares against the offer, not a fixed floor.
		if ( $offer && '' !== $offer['php'] && version_compare( $current, $offer['php'], '<' ) ) {
			return $this->item(
				'php_version',
				__( 'PHP version', 'upgrade-pilot' ),
				'fail',
				sprintf(
					/* translators: 1: current PHP version, 2: offered WordPress version, 3: required PHP version. */
					__( 'This server runs PHP %1$s. WordPress %2$s requires PHP %3$s, so this site cannot install that update until PHP is upgraded.', 'upgrade-pilot' ),
					$current,
					$offer['version'],
					$offer['php']
				)
			);
		}

		if ( version_compare( $current, $target_php, '<' ) ) {
			return $this->item(
				'php_version',
				__( 'PHP version', 'upgrade-pilot' ),
				'warn',
				sprintf(
					/* translators: 1: current PHP version, 2: target PHP version. */
					__( 'This server runs PHP %1$s. Your readiness target is PHP %2$s — the scan below checks whether your plugins are ready for it before you switch.', 'upgrade-pilot' ),
					$current,
					$target_php
				)
			);
		}

		return $this->item(
			'php_version',
			__( 'PHP version', 'upgrade-pilot' ),
			'pass',
			sprintf(
				/* translators: %s: current PHP version. */
				__( 'This server already runs PHP %s, which meets your target.', 'upgrade-pilot' ),
				$current
			)
		);
	}

	/**
	 * Is the running PHP branch still receiving security fixes?
	 *
	 * @return array
	 */
	private function check_php_eol() {
		$branch = implode( '.', array_slice( explode( '.', PHP_VERSION ), 0, 2 ) );
		$eol    = isset( self::PHP_EOL[ $branch ] ) ? self::PHP_EOL[ $branch ] : '';

		if ( '' === $eol ) {
			return $this->item(
				'php_eol',
				__( 'PHP security support', 'upgrade-pilot' ),
				'pass',
				sprintf(
					/* translators: %s: PHP branch. */
					__( 'PHP %s is a current branch.', 'upgrade-pilot' ),
					$branch
				)
			);
		}

		$eol_ts   = strtotime( $eol . ' 23:59:59' );
		$days     = (int) floor( ( $eol_ts - time() ) / DAY_IN_SECONDS );

		if ( $days < 0 ) {
			return $this->item(
				'php_eol',
				__( 'PHP security support', 'upgrade-pilot' ),
				'fail',
				sprintf(
					/* translators: 1: PHP branch, 2: EOL date. */
					__( 'PHP %1$s stopped receiving security fixes on %2$s. Unpatched PHP is a security risk — plan the upgrade now.', 'upgrade-pilot' ),
					$branch,
					$eol
				)
			);
		}

		if ( $days < 180 ) {
			return $this->item(
				'php_eol',
				__( 'PHP security support', 'upgrade-pilot' ),
				'warn',
				sprintf(
					/* translators: 1: PHP branch, 2: EOL date, 3: number of days. */
					__( 'PHP %1$s reaches end of security support on %2$s — %3$d days from now. Use the scan below to prepare.', 'upgrade-pilot' ),
					$branch,
					$eol,
					$days
				)
			);
		}

		return $this->item(
			'php_eol',
			__( 'PHP security support', 'upgrade-pilot' ),
			'pass',
			sprintf(
				/* translators: 1: PHP branch, 2: EOL date. */
				__( 'PHP %1$s receives security fixes until %2$s.', 'upgrade-pilot' ),
				$branch,
				$eol
			)
		);
	}

	/**
	 * Database version, checked the way CORE checks it — no invented floors.
	 *
	 * Core's logic, from list_core_update() in wp-admin/update-core.php:
	 *
	 *   $php_compat = version_compare( $php_version, $update->php_version, '>=' );
	 *   if ( file_exists( WP_CONTENT_DIR . '/db.php' ) && empty( $wpdb->is_mysql ) ) {
	 *       $mysql_compat = true;
	 *   } else {
	 *       $mysql_compat = version_compare( $mysql_version, $update->mysql_version, '>=' );
	 *   }
	 *
	 * So: a site running a non-MySQL backend behind a db.php drop-in (SQLite and
	 * friends) is EXEMPT, and the version to beat comes from the offered update,
	 * falling back to $required_mysql_version from wp-includes/version.php.
	 *
	 * @return array
	 */
	private function check_database() {
		global $wpdb;

		// Core's own exemption: an alternative (non-MySQL) backend via a db.php drop-in.
		if ( file_exists( WP_CONTENT_DIR . '/db.php' ) && empty( $wpdb->is_mysql ) ) {
			return $this->item(
				'db_version',
				__( 'Database version', 'upgrade-pilot' ),
				'pass',
				__( 'This site uses an alternative database backend through a db.php drop-in. WordPress treats such sites as compatible and does not apply its MySQL version requirement to them.', 'upgrade-pilot' )
			);
		}

		$raw        = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : (string) $wpdb->db_version();
		$is_mariadb = ( false !== stripos( $raw, 'mariadb' ) );
		$version    = (string) $wpdb->db_version(); // This is exactly what core compares.
		$engine     = $is_mariadb ? 'MariaDB' : 'MySQL';

		// Display the true MariaDB version, not the "5.5.5-" replication-compat prefix.
		$display = $version;
		if ( $is_mariadb && preg_match( '/(?:5\.5\.5-)?(\d+\.\d+(?:\.\d+)?)/', $raw, $m ) ) {
			$display = $m[1];
		}

		$minimum  = $this->core_minimums();
		$offer    = $this->offered_update();
		$required = ( $offer && '' !== $offer['mysql'] ) ? $offer['mysql'] : $minimum['mysql'];
		$for_what = ( $offer && '' !== $offer['mysql'] && '' !== $offer['version'] )
			? sprintf(
				/* translators: %s: WordPress version. */
				__( 'WordPress %s', 'upgrade-pilot' ),
				$offer['version']
			)
			: sprintf(
				/* translators: %s: WordPress version. */
				__( 'WordPress %s', 'upgrade-pilot' ),
				get_bloginfo( 'version' )
			);

		if ( version_compare( $version, $required, '<' ) ) {
			return $this->item(
				'db_version',
				__( 'Database version', 'upgrade-pilot' ),
				'fail',
				sprintf(
					/* translators: 1: engine, 2: version, 3: WordPress version phrase, 4: required version. */
					__( '%1$s %2$s is below the version %3$s requires (%4$s). WordPress will refuse to install the update until the database server is upgraded. Ask your host.', 'upgrade-pilot' ),
					$engine,
					$display,
					$for_what,
					$required
				)
			);
		}

		return $this->item(
			'db_version',
			__( 'Database version', 'upgrade-pilot' ),
			'pass',
			sprintf(
				/* translators: 1: engine, 2: version, 3: WordPress version phrase, 4: required version. */
				__( '%1$s %2$s meets the database requirement for %3$s (needs %4$s or newer).', 'upgrade-pilot' ),
				$engine,
				$display,
				$for_what,
				$required
			)
		);
	}

	/**
	 * Memory limit check.
	 *
	 * @return array
	 */
	private function check_memory() {
		$limit = wp_convert_hr_to_bytes( (string) ini_get( 'memory_limit' ) );

		if ( $limit > 0 && $limit < 128 * MB_IN_BYTES ) {
			return $this->item(
				'memory',
				__( 'PHP memory limit', 'upgrade-pilot' ),
				'warn',
				sprintf(
					/* translators: %s: memory limit. */
					__( 'PHP memory limit is %s. 256M or higher is recommended before running major upgrades.', 'upgrade-pilot' ),
					size_format( $limit )
				)
			);
		}

		return $this->item(
			'memory',
			__( 'PHP memory limit', 'upgrade-pilot' ),
			'pass',
			sprintf(
				/* translators: %s: memory limit. */
				__( 'PHP memory limit is %s.', 'upgrade-pilot' ),
				$limit > 0 ? size_format( $limit ) : __( 'unlimited', 'upgrade-pilot' )
			)
		);
	}

	/**
	 * Required/recommended PHP extensions.
	 *
	 * @return array[]
	 */
	private function check_extensions() {
		$required    = array( 'json', 'mysqli' );
		$recommended = array( 'curl', 'dom', 'mbstring', 'openssl', 'xml', 'zip', 'gd' );

		$items = array();

		foreach ( $required as $ext ) {
			if ( ! extension_loaded( $ext ) ) {
				$items[] = $this->item(
					'ext_' . $ext,
					sprintf(
						/* translators: %s: extension name. */
						__( 'PHP extension: %s', 'upgrade-pilot' ),
						$ext
					),
					'fail',
					sprintf(
						/* translators: %s: extension name. */
						__( 'The required PHP extension "%s" is missing.', 'upgrade-pilot' ),
						$ext
					)
				);
			}
		}

		$missing = array();
		foreach ( $recommended as $ext ) {
			if ( ! extension_loaded( $ext ) && ! ( 'gd' === $ext && extension_loaded( 'imagick' ) ) ) {
				$missing[] = $ext;
			}
		}

		if ( ! empty( $missing ) ) {
			$items[] = $this->item(
				'extensions',
				__( 'Recommended PHP extensions', 'upgrade-pilot' ),
				'warn',
				sprintf(
					/* translators: %s: extension list. */
					__( 'Missing recommended extensions: %s. Some features of WordPress or your plugins may not work.', 'upgrade-pilot' ),
					implode( ', ', $missing )
				)
			);
		} else {
			$items[] = $this->item(
				'extensions',
				__( 'Recommended PHP extensions', 'upgrade-pilot' ),
				'pass',
				__( 'All recommended PHP extensions are installed.', 'upgrade-pilot' )
			);
		}

		return $items;
	}

	/**
	 * HTTPS check.
	 *
	 * @return array
	 */
	private function check_https() {
		if ( is_ssl() && 0 === strpos( home_url(), 'https://' ) ) {
			return $this->item( 'https', __( 'HTTPS', 'upgrade-pilot' ), 'pass', __( 'The site is served over HTTPS.', 'upgrade-pilot' ) );
		}
		return $this->item( 'https', __( 'HTTPS', 'upgrade-pilot' ), 'warn', __( 'The site is not fully served over HTTPS.', 'upgrade-pilot' ) );
	}

	/**
	 * Build a check item.
	 *
	 * @param string $id     Item id.
	 * @param string $label  Human label.
	 * @param string $status pass|warn|fail.
	 * @param string $detail Explanation.
	 * @return array
	 */
	private function item( $id, $label, $status, $detail ) {
		return array(
			'id'     => $id,
			'label'  => $label,
			'status' => $status,
			'tier'   => 'fact',
			'detail' => $detail,
		);
	}
}
