<?php
/**
 * Plugin identity resolution: which installed plugins are wp.org-hosted, and under which slug.
 *
 * Discipline: never assume folder slug == wp.org slug. Classification order:
 *  1. Update URI header pointing away from wordpress.org  => external.
 *  2. Membership in core's update_plugins transient       => wp.org-hosted (authoritative slug).
 *  3. plugins/info API answer                             => ok/closed => wp.org, 404 => external.
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Identity
 */
class UPilot_Identity {

	const CACHE_OPTION = 'upilot_identity_map';

	/**
	 * API client.
	 *
	 * @var UPilot_WPOrg_Client
	 */
	private $client;

	/**
	 * Constructor.
	 *
	 * @param UPilot_WPOrg_Client $client wp.org API client.
	 */
	public function __construct( UPilot_WPOrg_Client $client ) {
		$this->client = $client;

		// Invalidate the identity map when the plugin set changes.
		add_action( 'activated_plugin', array( $this, 'flush' ) );
		add_action( 'deactivated_plugin', array( $this, 'flush' ) );
		add_action( 'deleted_plugin', array( $this, 'flush' ) );
		add_action( 'upgrader_process_complete', array( $this, 'flush_on_upgrade' ), 10, 2 );
	}

	/**
	 * Flush the cached identity map.
	 */
	public function flush() {
		UPilot_Store::delete_option( self::CACHE_OPTION );
	}

	/**
	 * Flush only when the completed upgrade actually touched plugins
	 * (translation/core/theme updates fire the same hook near-daily).
	 *
	 * @param object $upgrader   Upgrader instance (unused).
	 * @param array  $hook_extra Context.
	 */
	public function flush_on_upgrade( $upgrader, $hook_extra ) {
		if ( isset( $hook_extra['type'] ) && 'plugin' === $hook_extra['type'] ) {
			$this->flush();
		}
	}

	/**
	 * Classify all installed plugins.
	 *
	 * @param bool $rebuild Force rebuild.
	 * @return array<string,array> plugin_file => {slug, class: wporg|external, state, name, version, active}
	 */
	public function get_map( $rebuild = false ) {
		if ( ! $rebuild ) {
			$cached = UPilot_Store::get_option( self::CACHE_OPTION );
			if ( is_array( $cached ) && ! empty( $cached['map'] ) && isset( $cached['built_at'] ) && ( time() - $cached['built_at'] ) < DAY_IN_SECONDS ) {
				return $this->stamp_active( $cached['map'] );
			}
		}

		if ( ! function_exists( 'get_plugins' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$installed = get_plugins();
		$update_tx = get_site_transient( 'update_plugins' );
		$known     = array();

		// Core's own update check already resolved wp.org identity for these.
		foreach ( array( 'response', 'no_update' ) as $bucket ) {
			if ( isset( $update_tx->$bucket ) && is_array( $update_tx->$bucket ) ) {
				foreach ( $update_tx->$bucket as $file => $item ) {
					$item = (object) $item;
					if ( ! empty( $item->slug ) && ( empty( $item->url ) || false !== strpos( (string) $item->url, 'wordpress.org' ) ) ) {
						$known[ $file ] = $item->slug;
					}
				}
			}
		}

		$map      = array();
		$to_probe = array(); // plugin_file => guessed slug, resolved in ONE batch below.

		foreach ( $installed as $file => $headers ) {
			// Best-guess slug up front so EVERY exit path has a usable, non-colliding slug.
			$guess = dirname( $file );
			if ( '.' === $guess ) {
				$guess = basename( $file, '.php' );
			}

			$entry = array(
				'name'    => isset( $headers['Name'] ) ? $headers['Name'] : $file,
				'version' => isset( $headers['Version'] ) ? $headers['Version'] : '',
				'active'  => is_plugin_active( $file ),
				'slug'    => $guess,
				'class'   => 'unknown',
				'state'   => 'unknown',
			);

			// Rule 1: Update URI header (WP 5.8+). A value of "false" is core's
			// documented opt-out for private plugins => external, never guessed
			// against someone else's wp.org slug.
			$update_uri = isset( $headers['UpdateURI'] ) ? trim( $headers['UpdateURI'] ) : '';
			if ( '' !== $update_uri && ( 'false' === strtolower( $update_uri ) || false === strpos( $update_uri, 'wordpress.org' ) ) ) {
				$entry['class'] = 'external';
				$entry['state'] = 'external';
				$map[ $file ]   = $entry;
				continue;
			}

			// Rule 2: core update transient membership.
			if ( isset( $known[ $file ] ) ) {
				$entry['class'] = 'wporg';
				$entry['slug']  = $known[ $file ];
				$entry['state'] = 'ok';
				$map[ $file ]   = $entry;
				continue;
			}

			// Rule 3: needs the info API. Collect, do not fetch yet — resolving
			// these one at a time was N sequential HTTP calls on a cold cache.
			$map[ $file ]      = $entry;
			$to_probe[ $file ] = $guess;
		}

		// One batched call resolves every remaining plugin at once.
		if ( ! empty( $to_probe ) ) {
			$infos = $this->client->get_plugins_info( array_values( $to_probe ) );

			foreach ( $to_probe as $file => $slug ) {
				$state = isset( $infos[ $slug ]['state'] ) ? $infos[ $slug ]['state'] : 'api_error';
				switch ( $state ) {
					case 'ok':
						$map[ $file ]['class'] = 'wporg';
						$map[ $file ]['state'] = 'ok';
						break;
					case 'closed':
						$map[ $file ]['class'] = 'wporg';
						$map[ $file ]['state'] = 'closed';
						break;
					case 'not_found':
						$map[ $file ]['class'] = 'external';
						$map[ $file ]['state'] = 'external';
						break;
					default:
						// API error: leave unknown; do NOT alert on unknowns.
						$map[ $file ]['class'] = 'unknown';
						$map[ $file ]['state'] = 'unknown';
				}
			}
		}

		UPilot_Store::update_option(
			self::CACHE_OPTION,
			array(
				'built_at' => time(),
				'map'      => $map,
			)
		);

		return $this->stamp_active( $map );
	}

	/**
	 * Stamp the per-subsite "is this active here?" flag onto a cached map.
	 *
	 * On a network the identity map is cached network-wide, because which plugins
	 * are INSTALLED and who wrote them is a property of the network. But which
	 * plugins are ACTIVE is the one fact that genuinely differs per subsite, so it
	 * must never be baked into that shared cache — subsite 2's active list would
	 * be served to subsite 3. It is therefore recomputed on every read, from the
	 * current subsite's own option.
	 *
	 * @param array<string,array> $map Cached map.
	 * @return array<string,array>
	 */
	private function stamp_active( $map ) {
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		foreach ( $map as $file => $entry ) {
			$map[ $file ]['active'] = is_plugin_active( $file );
		}
		return $map;
	}

	/**
	 * Convenience: wp.org-hosted subset of the map.
	 *
	 * @return array<string,array>
	 */
	public function get_wporg_plugins() {
		return array_filter(
			$this->get_map(),
			function ( $entry ) {
				return 'wporg' === $entry['class'];
			}
		);
	}
}
