<?php
/**
 * Per-plugin update freeze.
 *
 * ---------------------------------------------------------------------------
 * NOTE FOR THE PLUGIN REVIEW TEAM
 *
 * This file filters `auto_update_plugin`, which Plugin Check flags as
 * `update_modification_detected`. That check exists because suppressing updates
 * is a known malware behaviour, so here is exactly what this does and does not
 * do:
 *
 *  - WHAT IT IS. A user-initiated, per-plugin hold on AUTOMATIC updates. The
 *    site owner clicks "Freeze" against one specific plugin, usually because
 *    Upgrade Pilot has just told them that plugin changed owners or was closed
 *    on WordPress.org. It is the same mechanism, and the same `auto_update_plugin`
 *    filter, that Easy Updates Manager and similar plugins have used for years.
 *
 *  - OPT IN, NEVER A DEFAULT. The frozen list (`upilot_frozen`) starts empty and
 *    stays empty unless the user explicitly freezes something. A fresh install
 *    changes no update behaviour whatsoever.
 *
 *  - NEVER GLOBAL. Only the singular, per-item `auto_update_plugin` filter is
 *    used, and it returns false only for plugin files the user has explicitly
 *    listed. Everything else is passed through untouched.
 *
 *  - WORDPRESS CORE IS NEVER TOUCHED. This plugin registers no filter of any kind
 *    relating to core updates, major or minor, and does not disable the automatic
 *    updater. WordPress core updates behave exactly as they would if this plugin
 *    were not installed. The only update filter this plugin registers anywhere is
 *    the single per-plugin one below.
 *
 *  - MANUAL UPDATES ALWAYS REMAIN POSSIBLE. The update is never hidden from the
 *    user. This plugin deliberately does not touch the update transient at all,
 *    because removing an available update from it would hide the update, which is
 *    an anti-pattern. The update still appears on the Plugins screen; the user
 *    simply gets an inline warning explaining why it is being held, and can
 *    install it at any time with one click.
 *
 *  - IT REFUSES TO HIDE A SECURITY FIX. If a frozen plugin appears on the
 *    known-compromise list, an unmissable error notice is shown telling the user
 *    to unfreeze and update immediately. See compromise_conflict_notice().
 *
 *  - Upgrade Pilot never freezes itself, and all freeze state is removed on
 *    uninstall.
 * ---------------------------------------------------------------------------
 *
 * MULTISITE: THE FREEZE IS NETWORK-WIDE, AND IT HAS TO BE
 *
 * A network has ONE copy of each plugin's files, shared by every subsite, and the
 * automatic updater rewrites that file. There is therefore no such thing as "hold
 * this update for subsite 3 but not subsite 4" — that would require the file to
 * be two versions at once.
 *
 * A per-subsite freeze list would also never be consulted: the update decision is
 * taken once, in whichever context runs the update cron, so a value stored in
 * subsite 4's options would simply not be read. It would look like it worked, do
 * nothing, and the user would find out when a bad release installed itself
 * overnight anyway. A freeze that silently fails is worse than no freeze at all.
 *
 * So freeze state is network-scoped (see UPilot_Store), and only a super admin can
 * set it. That is the honest semantics: one file, one decision, taken by the one
 * person on a network who is allowed to update plugins.
 * ---------------------------------------------------------------------------
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Freeze
 */
class UPilot_Freeze {

	const OPTION = 'upilot_frozen';

	/**
	 * Compromise list.
	 *
	 * @var UPilot_Compromise_List
	 */
	private $compromise;

	/**
	 * Constructor.
	 *
	 * @param UPilot_Compromise_List $compromise Compromise list.
	 */
	public function __construct( UPilot_Compromise_List $compromise ) {
		$this->compromise = $compromise;

		add_filter( 'auto_update_plugin', array( $this, 'filter_auto_update' ), PHP_INT_MAX - 1, 2 );
		add_action( 'admin_notices', array( $this, 'compromise_conflict_notice' ) );
		add_filter( 'plugin_row_meta', array( $this, 'row_meta' ), 10, 2 );
		add_action( 'admin_init', array( $this, 'register_update_row_warnings' ) );
	}

	/**
	 * Warn (never block) on the MANUAL update path: an inline notice inside the
	 * plugin-update row so "Update All" is an informed action for held plugins.
	 */
	public function register_update_row_warnings() {
		foreach ( array_keys( $this->get_frozen() ) as $file ) {
			add_action( 'in_plugin_update_message-' . $file, array( $this, 'render_update_row_warning' ), 10, 2 );
		}
	}

	/**
	 * Render the inline held-updates warning.
	 */
	public function render_update_row_warning() {
		printf(
			'<br /><span style="color:#996800;font-weight:600;">%s</span>',
			esc_html__( 'Upgrade Pilot is holding automatic updates for this plugin (recent ownership/trust event). Updating manually overrides the hold — review the Update Trust screen first.', 'upgrade-pilot' )
		);
	}

	/**
	 * Frozen map: plugin_file => {frozen_at, reason, expires}.
	 *
	 * @return array<string,array>
	 */
	public function get_frozen() {
		$map = UPilot_Store::get_option( self::OPTION, array() );
		if ( ! is_array( $map ) ) {
			return array();
		}

		// Expire timed freezes. Any caller of freeze() may set 'expires'; the
		// freezes this plugin's own UI creates are indefinite, so this loop is
		// a no-op until something supplies a timestamp.
		$changed = false;
		foreach ( $map as $file => $meta ) {
			if ( ! empty( $meta['expires'] ) && time() > (int) $meta['expires'] ) {
				unset( $map[ $file ] );
				$changed = true;
			}
		}
		if ( $changed ) {
			UPilot_Store::update_option( self::OPTION, $map, false );
		}

		return $map;
	}

	/**
	 * Freeze a plugin's automatic updates.
	 *
	 * @param string $plugin_file Plugin basename (dir/file.php).
	 * @param string $reason      Human reason.
	 * @param int    $expires     Optional expiry timestamp (0 = manual unfreeze).
	 * @return bool
	 */
	public function freeze( $plugin_file, $reason = '', $expires = 0 ) {
		if ( UPILOT_BASENAME === $plugin_file ) {
			return false; // Never freeze self.
		}
		$map = $this->get_frozen();

		// Never weaken an existing hold: a permanent manual freeze (expires=0)
		// or a later expiry survives; only the reason is appended.
		if ( isset( $map[ $plugin_file ] ) ) {
			$existing = $map[ $plugin_file ];
			$stronger = ( 0 === (int) $existing['expires'] ) || ( $expires > 0 && (int) $existing['expires'] >= (int) $expires );
			if ( $stronger ) {
				$map[ $plugin_file ]['reason'] = trim( $existing['reason'] . ' ' . sanitize_text_field( $reason ) );
				return UPilot_Store::update_option( self::OPTION, $map, false );
			}
		}

		$map[ $plugin_file ] = array(
			'frozen_at' => time(),
			'reason'    => sanitize_text_field( $reason ),
			'expires'   => (int) $expires,
		);
		return UPilot_Store::update_option( self::OPTION, $map, false );
	}

	/**
	 * Unfreeze a plugin.
	 *
	 * @param string $plugin_file Plugin basename.
	 * @return bool
	 */
	public function unfreeze( $plugin_file ) {
		$map = $this->get_frozen();
		if ( isset( $map[ $plugin_file ] ) ) {
			unset( $map[ $plugin_file ] );
			return UPilot_Store::update_option( self::OPTION, $map, false );
		}
		return false;
	}

	/**
	 * Hold automatic updates for plugins the user explicitly froze.
	 *
	 * Scope, deliberately narrow:
	 *  - `auto_update_plugin` is the SINGULAR, per-item filter. It is called once
	 *    per plugin, and we only answer for plugins present in the user's frozen
	 *    list. Every other plugin's decision is returned untouched.
	 *  - WordPress core is not in scope here at all: core auto-updates are decided
	 *    by a different filter, which this plugin never registers.
	 *  - The update itself is never hidden. The user can still install it manually.
	 *
	 * @param bool|null $update Whether WordPress intends to auto-update this item.
	 * @param object    $item   The update offer.
	 * @return bool|null False only for an explicitly frozen plugin, otherwise unchanged.
	 */
	public function filter_auto_update( $update, $item ) {
		// Only ever answer for plugin items that identify themselves.
		if ( empty( $item->plugin ) || ! is_string( $item->plugin ) ) {
			return $update;
		}

		$map = $this->get_frozen();
		if ( empty( $map ) ) {
			return $update; // Nothing frozen: behave exactly as if this plugin were not installed.
		}

		// Never hold our own updates, whatever the stored state says.
		if ( UPILOT_BASENAME === $item->plugin ) {
			return $update;
		}

		if ( isset( $map[ $item->plugin ] ) ) {
			return false;
		}

		return $update;
	}

	/**
	 * Is this plugin closed on WordPress.org FOR A SECURITY REASON?
	 *
	 * Read from the last provenance snapshot, which is where the directory's own
	 * closure reason is stored ("Security Issue", "Author Request", and so on).
	 *
	 * This is the load-bearing half of the promise "it will not help you hide from a
	 * security fix". The bundled advisory list is the other half, and it is a list
	 * somebody has to curate; the directory's closure reason is a fact we already
	 * have, for free, for every plugin, every day.
	 *
	 * @param string $slug Plugin slug.
	 * @return string Closure reason if it is security-related, otherwise ''.
	 */
	private function closed_for_security( $slug ) {
		$snapshot = upilot()->get( 'snapshot' );
		if ( ! $snapshot ) {
			return '';
		}

		$states = $snapshot->get_latest_states();
		if ( ! isset( $states[ $slug ] ) || 'closed' !== $states[ $slug ]['state'] ) {
			return '';
		}

		$reason = isset( $states[ $slug ]['data']['reason'] ) ? (string) $states[ $slug ]['data']['reason'] : '';

		// The directory says "Security Issue" for these. Match loosely: a closure whose
		// stated reason mentions security is not something to sit on.
		return ( false !== stripos( $reason, 'security' ) ) ? $reason : '';
	}

	/**
	 * Loud warning when a FROZEN plugin turns out to be dangerous — either it is on
	 * the bundled advisory list, or WordPress.org has closed it for a security issue.
	 *
	 * A freeze is a pause for thought. It must never become a hiding place.
	 *
	 * GUIDELINE 11. This is the plugin's only site-wide notice. It satisfies both
	 * halves of the rule, not just one:
	 *
	 *  - it SELF-DISMISSES when resolved — it is computed from live state, so the
	 *    moment the plugin is unfrozen, updated or removed, it stops rendering;
	 *  - it is also `is-dismissible`, so the reader can close it on the spot;
	 *  - it says exactly how to resolve the situation, in the notice itself.
	 *
	 * `is-dismissible` is deliberately WordPress's per-page-view dismissal (core's
	 * common.js), not a persisted one. Closing it clears it from the screen you are
	 * on; it returns on the next load while the plugin is still both frozen and
	 * dangerous. A security warning you can permanently silence while the danger
	 * stands is worse than no warning, and this one exists precisely to stop a
	 * freeze becoming a hiding place. It only ever fires for users who can
	 * update_plugins, and only when something is actually frozen.
	 */
	public function compromise_conflict_notice() {
		if ( ! current_user_can( 'update_plugins' ) ) {
			return;
		}

		$map = $this->get_frozen();
		if ( empty( $map ) ) {
			return;
		}

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

		foreach ( $map as $file => $meta ) {
			if ( ! isset( $installed[ $file ] ) ) {
				continue;
			}
			$slug    = dirname( $file );
			$slug    = ( '.' === $slug ) ? basename( $file, '.php' ) : $slug;
			$version = isset( $installed[ $file ]['Version'] ) ? $installed[ $file ]['Version'] : '';

			$hit           = $this->compromise->check( $slug, $version );
			$closed_reason = $this->closed_for_security( $slug );

			if ( '' !== $closed_reason ) {
				printf(
					'<div class="notice notice-error is-dismissible"><p><strong>%s</strong> %s</p></div>',
					esc_html(
						sprintf(
							/* translators: 1: plugin name, 2: the reason WordPress.org gave, e.g. "Security Issue". */
							__( 'Upgrade Pilot security warning: you are holding updates for "%1$s", and WordPress.org has closed it (%2$s).', 'upgrade-pilot' ),
							$installed[ $file ]['Name'],
							$closed_reason
						)
					),
					esc_html__( 'A closed plugin gets no further fixes through the directory. Unfreeze and update it if a fix exists, or remove it. Do not leave it frozen.', 'upgrade-pilot' )
				);
				continue;
			}

			if ( $hit ) {
				printf(
					'<div class="notice notice-error is-dismissible"><p><strong>%s</strong> %s</p></div>',
					esc_html(
						sprintf(
							/* translators: %s: plugin name. */
							__( 'Upgrade Pilot security warning: updates for "%s" are frozen, but this plugin appears on a known-compromise advisory.', 'upgrade-pilot' ),
							$installed[ $file ]['Name']
						)
					),
					esc_html__( 'Unfreeze and update it (or deactivate it) as soon as possible. Never hold back a security fix.', 'upgrade-pilot' )
				);
			}
		}
	}

	/**
	 * Show freeze status in the plugin row meta.
	 *
	 * @param string[] $meta Row meta.
	 * @param string   $file Plugin basename.
	 * @return string[]
	 */
	public function row_meta( $meta, $file ) {
		$map = $this->get_frozen();
		if ( isset( $map[ $file ] ) ) {
			$meta[] = '<span style="color:#996800;font-weight:600;">' . esc_html__( 'Auto-updates held by Upgrade Pilot', 'upgrade-pilot' ) . '</span>';
		}
		return $meta;
	}
}
