<?php
/**
 * Engine A: per-plugin/theme wp.org metadata cross-reference (FACT tier).
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Metadata_Check
 */
class UPilot_Metadata_Check {

	/**
	 * Recent WordPress major releases, oldest first. Bump each release
	 * (release-cadence maintenance is part of wp.org ranking hygiene anyway).
	 *
	 * @var string[]
	 */
	const WP_MAJORS = array( '5.9', '6.0', '6.1', '6.2', '6.3', '6.4', '6.5', '6.6', '6.7', '6.8', '6.9', '7.0' );

	/**
	 * Identity resolver.
	 *
	 * @var UPilot_Identity
	 */
	private $identity;

	/**
	 * Constructor.
	 *
	 * @param UPilot_Identity $identity Identity resolver.
	 */
	public function __construct( UPilot_Identity $identity ) {
		$this->identity = $identity;
	}

	/**
	 * Cross-reference every installed plugin against wp.org metadata.
	 *
	 * @param string $target_php Target PHP version.
	 * @return array{items:array[],summary:array<string,int>}
	 */
	public function run( $target_php ) {
		$settings = UPilot_Store::get_option( 'upilot_settings', array() );
		$stale_majors     = isset( $settings['stale_tested_majors'] ) ? (int) $settings['stale_tested_majors'] : 3;
		$abandoned_months = isset( $settings['abandoned_months'] ) ? (int) $settings['abandoned_months'] : 24;

		$plugin = upilot()->get( 'wporg' );

		$map = $this->identity->get_map();

		// ONE batched call for every wp.org plugin, not one call per plugin.
		//
		// This used to loop and call get_plugin_info() per plugin. Slugs that the
		// identity resolver had settled from core's update transient were never
		// cached, so the render path fired one HTTP request each until it hit its
		// budget of 10 — and everything after that, including the themes, came back
		// as "could not verify". The user saw a report full of shrugs and no
		// explanation. Batching resolves the whole site in a single request; cached
		// slugs cost nothing at all.
		$slugs = array();
		foreach ( $map as $entry ) {
			if ( 'wporg' === $entry['class'] ) {
				$slugs[ $entry['slug'] ] = $entry['slug'];
			}
		}

		// A generous budget now buys the whole site: one batch for the plugins, plus
		// one small request per theme. It exists only so a cold cache cannot stall
		// the page indefinitely.
		$plugin->set_http_budget( 12 );
		$infos = empty( $slugs ) ? array() : $plugin->get_plugins_info( array_values( $slugs ) );
		$items   = array();
		$summary = array(
			'pass'     => 0,
			'warn'     => 0,
			'fail'     => 0,
			'external' => 0,
			'unknown'  => 0,
		);

		$current_wp_index = count( self::WP_MAJORS ) - 1;

		foreach ( $map as $file => $entry ) {
			if ( 'external' === $entry['class'] ) {
				$summary['external']++;
				$items[] = $this->item( $entry, 'external', 'pass', __( 'Not hosted on WordPress.org (premium or custom). Static scan still applies; metadata checks are skipped.', 'upgrade-pilot' ), 'external' );
				continue;
			}
			if ( 'unknown' === $entry['class'] ) {
				$summary['unknown']++;
				$items[] = $this->item( $entry, 'unknown', 'pass', __( 'Could not verify against WordPress.org right now (API unreachable). No conclusions drawn.', 'upgrade-pilot' ), 'unknown' );
				continue;
			}

			$info = isset( $infos[ $entry['slug'] ] )
				? $infos[ $entry['slug'] ]
				: array(
					'state' => 'api_error',
					'data'  => array(),
				);

			if ( 'closed' === $info['state'] ) {
				$summary['fail']++;
				$reason  = ! empty( $info['data']['reason'] ) ? $info['data']['reason'] : __( 'unknown reason', 'upgrade-pilot' );
				$is_sec  = ( false !== stripos( $reason, 'security' ) );
				$items[] = $this->item(
					$entry,
					'closed',
					'fail',
					sprintf(
						/* translators: 1: closure date, 2: reason. */
						__( 'This plugin was closed on WordPress.org (%1$s). Reason: %2$s. It will receive no further updates through the plugin directory.', 'upgrade-pilot' ),
						! empty( $info['data']['closed_date'] ) ? $info['data']['closed_date'] : __( 'date unknown', 'upgrade-pilot' ),
						$reason
					),
					$is_sec ? 'critical' : 'closed'
				);
				continue;
			}

			if ( 'ok' !== $info['state'] ) {
				$summary['unknown']++;
				$items[] = $this->item( $entry, 'unknown', 'pass', __( 'Could not verify against WordPress.org right now (API unreachable). No conclusions drawn.', 'upgrade-pilot' ), 'unknown' );
				continue;
			}

			$data     = $info['data'];
			$statuses = array();

			// FACT: plugin's own declared PHP floor exceeds the target.
			// Compare major.minor only: "8.1.0" must NOT read as newer than target "8.1".
			$requires_php_mm = $this->normalize_major( isset( $data['requires_php'] ) ? $data['requires_php'] : '' );
			if ( '' !== $requires_php_mm && version_compare( $requires_php_mm, $target_php, '>' ) ) {
				$statuses[] = array(
					'fail',
					sprintf(
						/* translators: 1: required PHP, 2: target PHP. */
						__( 'Declares it requires PHP %1$s — newer than your target PHP %2$s.', 'upgrade-pilot' ),
						$data['requires_php'],
						$target_php
					),
					'requires_php',
				);
			}

			// Staleness: tested-up-to more than N majors behind. Versions OLDER than
			// our known-majors list are the MOST stale — clamp them to maximum distance
			// instead of silently exempting exactly the plugins this check exists for.
			$tested_major = $this->normalize_major( isset( $data['tested'] ) ? $data['tested'] : '' );
			$tested_index = array_search( $tested_major, self::WP_MAJORS, true );
			if ( false === $tested_index && '' !== $tested_major && version_compare( $tested_major, self::WP_MAJORS[0], '<' ) ) {
				$tested_index = -1; // Older than the whole list: maximally behind.
			}
			if ( '' !== $tested_major && false !== $tested_index && ( $current_wp_index - $tested_index ) >= $stale_majors ) {
				$statuses[] = array(
					'warn',
					sprintf(
						/* translators: 1: tested version, 2: number of majors. */
						__( 'Last tested on WordPress %1$s — %2$d or more major releases behind. It may still work, but nobody has vouched for it recently.', 'upgrade-pilot' ),
						$tested_major,
						$current_wp_index - max( -1, $tested_index )
					),
					'stale_tested',
				);
			}

			// Abandonment: last update older than the threshold.
			$months = $this->months_since( isset( $data['last_updated'] ) ? $data['last_updated'] : '' );
			if ( null !== $months && $months >= $abandoned_months ) {
				$statuses[] = array(
					'warn',
					sprintf(
						/* translators: %d: months. */
						__( 'Not updated in %d months. Abandoned plugins are the most common source of upgrade failures and security issues.', 'upgrade-pilot' ),
						$months
					),
					'abandoned',
				);
			}

			if ( empty( $statuses ) ) {
				$summary['pass']++;
				$items[] = $this->item(
					$entry,
					'ok',
					'pass',
					sprintf(
						/* translators: 1: tested version, 2: last updated date. */
						__( 'Actively maintained (tested to WP %1$s, updated %2$s).', 'upgrade-pilot' ),
						$tested_major ? $tested_major : '?',
						isset( $data['last_updated'] ) ? mysql2date( get_option( 'date_format' ), $data['last_updated'] ) : '?'
					),
					'ok'
				);
				continue;
			}

			// Worst status wins for the row; keep all messages.
			$worst = 'warn';
			foreach ( $statuses as $s ) {
				if ( 'fail' === $s[0] ) {
					$worst = 'fail';
				}
			}
			$summary[ $worst ]++;
			$items[] = $this->item(
				$entry,
				$statuses[0][2],
				$worst,
				implode( ' ', wp_list_pluck( $statuses, 1 ) ),
				$worst === 'fail' ? 'blocker' : 'review'
			);
		}

		// Themes get the same treatment. A site typically has one to five of them, and
		// an abandoned theme is exactly as capable of breaking a PHP upgrade — or of
		// changing hands — as an abandoned plugin. The readme promises this; the code
		// has to actually do it.
		$this->check_themes( $target_php, $items, $summary );

		return array(
			'items'   => $items,
			'summary' => $summary,
		);
	}

	/**
	 * Cross-reference every installed THEME against its WordPress.org record.
	 *
	 * Themes are cheap: a site has a handful, not dozens, so this is a small number of
	 * cached lookups rather than a batch. A theme with no wp.org record is treated the
	 * same way an off-directory plugin is — reported as external, never guessed at.
	 *
	 * @param string $target_php Target PHP version.
	 * @param array  $items      By-ref item list.
	 * @param array  $summary    By-ref summary counters.
	 */
	private function check_themes( $target_php, &$items, &$summary ) {
		if ( ! function_exists( 'wp_get_themes' ) ) {
			require_once ABSPATH . 'wp-includes/theme.php';
		}

		$client        = upilot()->get( 'wporg' );
		$active        = wp_get_theme();
		$active_stylesheet = $active->get_stylesheet();

		foreach ( wp_get_themes() as $stylesheet => $theme ) {
			$entry = array(
				'slug'    => $stylesheet,
				'name'    => $theme->get( 'Name' ) ? $theme->get( 'Name' ) : $stylesheet,
				'version' => (string) $theme->get( 'Version' ),
				'active'  => ( $stylesheet === $active_stylesheet ),
				'type'    => 'theme',
			);

			$info = $client->get_theme_info( $stylesheet );

			if ( 'api_error' === $info['state'] ) {
				$summary['unknown']++;
				$items[] = $this->item( $entry, 'unknown', 'pass', __( 'Could not verify this theme against WordPress.org right now (API unreachable). No conclusions drawn.', 'upgrade-pilot' ), 'unknown' );
				continue;
			}

			if ( 'not_found' === $info['state'] ) {
				$summary['external']++;
				$items[] = $this->item( $entry, 'external', 'pass', __( 'Theme not hosted on WordPress.org (premium or custom). Metadata checks are skipped.', 'upgrade-pilot' ), 'external' );
				continue;
			}

			$data     = $info['data'];
			$statuses = array();

			// Does the theme demand a PHP version higher than the one you are moving to?
			$requires_php = isset( $data['requires_php'] ) ? (string) $data['requires_php'] : '';
			if ( '' !== $requires_php && '' !== $target_php ) {
				$needs = $this->normalize_major( $requires_php );
				if ( '' !== $needs && version_compare( $needs, $target_php, '>' ) ) {
					$statuses[] = array(
						'fail',
						sprintf(
							/* translators: 1: PHP version the theme needs, 2: the target PHP version. */
							__( 'This theme needs PHP %1$s, which is newer than your target of PHP %2$s.', 'upgrade-pilot' ),
							$needs,
							$target_php
						),
						'requires_php',
					);
				}
			}

			// Has anybody touched it this decade?
			$months = $this->months_since( isset( $data['last_updated'] ) ? $data['last_updated'] : '' );
			$settings         = UPilot_Store::get_option( 'upilot_settings', array() );
			$abandoned_months = isset( $settings['abandoned_months'] ) ? (int) $settings['abandoned_months'] : 24;
			if ( null !== $months && $months >= $abandoned_months ) {
				$statuses[] = array(
					'warn',
					sprintf(
						/* translators: %d: number of months. */
						__( 'This theme has not been updated in %d months.', 'upgrade-pilot' ),
						$months
					),
					'abandoned',
				);
			}

			if ( empty( $statuses ) ) {
				$summary['pass']++;
				$items[] = $this->item(
					$entry,
					'ok',
					'pass',
					sprintf(
						/* translators: %s: last updated date. */
						__( 'Theme is actively maintained (updated %s).', 'upgrade-pilot' ),
						isset( $data['last_updated'] ) && $data['last_updated'] ? mysql2date( get_option( 'date_format' ), $data['last_updated'] ) : '?'
					),
					'ok'
				);
				continue;
			}

			$worst = 'warn';
			foreach ( $statuses as $s ) {
				if ( 'fail' === $s[0] ) {
					$worst = 'fail';
				}
			}
			$summary[ $worst ]++;
			$items[] = $this->item(
				$entry,
				$statuses[0][2],
				$worst,
				implode( ' ', wp_list_pluck( $statuses, 1 ) ),
				'fail' === $worst ? 'blocker' : 'review'
			);
		}
	}

	/**
	 * Build a result row.
	 *
	 * @param array  $entry    Identity entry.
	 * @param string $kind     Finding kind.
	 * @param string $status   pass|warn|fail.
	 * @param string $detail   Explanation.
	 * @param string $badge    Badge slug for the UI.
	 * @return array
	 */
	private function item( $entry, $kind, $status, $detail, $badge ) {
		return array(
			'slug'    => $entry['slug'],
			'name'    => $entry['name'],
			'version' => $entry['version'],
			'active'  => ! empty( $entry['active'] ),
			'kind'    => $kind,
			'status'  => $status,
			'tier'    => 'fact',
			'detail'  => $detail,
			'badge'   => $badge,
		);
	}

	/**
	 * Normalise a version to major.minor.
	 *
	 * @param string $version Version string.
	 * @return string
	 */
	private function normalize_major( $version ) {
		if ( ! is_string( $version ) || '' === $version ) {
			return '';
		}
		$parts = explode( '.', $version );
		if ( count( $parts ) < 2 ) {
			return $version;
		}
		return $parts[0] . '.' . $parts[1];
	}

	/**
	 * Months elapsed since a wp.org datetime string.
	 *
	 * @param string $datetime e.g. "2023-12-14 8:15pm GMT".
	 * @return int|null
	 */
	private function months_since( $datetime ) {
		if ( '' === $datetime ) {
			return null;
		}
		$ts = strtotime( $datetime );
		if ( false === $ts ) {
			return null;
		}
		return (int) floor( ( time() - $ts ) / ( 30 * DAY_IN_SECONDS ) );
	}
}
