<?php
/**
 * Uninstall cleanup.
 *
 * ---------------------------------------------------------------------------
 * WHY THIS IS A CLASS AND NOT uninstall.php
 *
 * WordPress runs EITHER uninstall.php OR the registered uninstall hook. Never
 * both. Read wp-admin/includes/plugin.php, uninstall_plugin():
 *
 *     if ( file_exists( .../uninstall.php ) ) {
 *         if ( isset( $uninstallable_plugins[ $file ] ) ) {
 *             unset( $uninstallable_plugins[ $file ] );      // <- deletes the
 *             update_option( 'uninstall_plugins', ... );     //    registered hook
 *         }
 *         include_once .../uninstall.php;
 *         return true;                                       // <- and stops
 *     }
 *
 * So while an uninstall.php exists, the Freemius SDK's uninstall hook is not
 * merely skipped — it is actively deregistered. That is why Freemius rejects a
 * plugin that ships one.
 *
 * The cleanup therefore moves onto Freemius's `after_uninstall` action. Two
 * things had to be verified in the SDK before that was safe, because getting it
 * wrong orphans a user's tables forever:
 *
 *   1. DOES IT FIRE FOR AN ANONYMOUS USER — someone who skipped the opt-in, which
 *      is most WordPress.org users? YES. In class-freemius.php the call is
 *
 *          9894:   $fs->_uninstall_plugin_event();      // <- inside is_registered()
 *          9897:   $fs->do_action( 'after_uninstall' ); // <- OUTSIDE it
 *
 *      Only the server-side deregistration is gated on registration. The action
 *      itself fires for everyone.
 *
 *   2. DOES IT FIRE IF THE SDK IS NOT THERE? NO. The hook callable is the static
 *      Freemius::_uninstall_plugin_hook(), and at :9873 it does nothing unless
 *      get_instance_by_file() returns an object.
 *
 * Answer 2 is why safety_net() below exists.
 * ---------------------------------------------------------------------------
 *
 * @package Upgrade_Pilot
 */

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

/**
 * Class UPilot_Uninstall
 */
class UPilot_Uninstall {

	/**
	 * Has the cleanup already run this request? Both entry points are allowed to
	 * fire; only one of them should do the work.
	 *
	 * @var bool
	 */
	private static $done = false;

	/**
	 * Every option this plugin has ever written.
	 *
	 * @return string[]
	 */
	private static function options() {
		return array(
			'upilot_settings',
			'upilot_db_version',
			'upilot_scan_state',
			'upilot_digest_queue',
			'upilot_frozen',
			'upilot_suppressed',
			'upilot_last_report',
			'upilot_identity_map',
			'upilot_api_breaker',
			'upilot_scan_lock',
			'upilot_last_snapshot_run',
			'upilot_network_rollup_at',
			'upilot_pro_settings',  // Written only by the separately-installed Pro build; deleted here so
			'upilot_pro_delivery',  // uninstalling the free copy leaves no residue behind. Never read here.
			'upilot_activated', // Retired; deleted so an upgrade from any dev build is clean.
		);
	}

	/**
	 * Every cron hook this plugin has ever registered.
	 *
	 * @return string[]
	 */
	private static function hooks() {
		return array(
			'upilot_daily_snapshot',
			'upilot_weekly_rescan',
			'upilot_queue_tick',
			'upilot_daily_digest',
			'upilot_network_rollup',
			'upilot_pro_scheduled_scan', // Scheduled only by the separately-installed Pro build; cleared here
			'upilot_pro_client_digest',  // so uninstalling the free copy leaves no orphaned cron entries.
		);
	}

	/**
	 * The Freemius-sanctioned entry point: fires on `after_uninstall`.
	 */
	public static function run() {
		self::cleanup();
	}

	/**
	 * The belt-and-braces entry point.
	 *
	 * WordPress include_once's the plugin's MAIN FILE before firing the uninstall
	 * hook (uninstall_plugin(), the second branch). That means this class is loaded
	 * and can register itself on the very same hook, with no dependency on the
	 * Freemius SDK being healthy. If the SDK is missing or its instance cannot be
	 * resolved, `after_uninstall` never fires — and without this, the user's tables
	 * would be orphaned forever.
	 *
	 * It runs at a late priority so the SDK's own handler goes first, and it no-ops
	 * if that handler already did the work.
	 */
	public static function safety_net() {
		if ( self::$done ) {
			return; // The SDK path already cleaned up.
		}

		// Mirror the SDK's own guard (class-freemius.php:9878): if the OTHER build of
		// this plugin is still installed and active, its data is still in use. Deleting
		// the free copy while the Pro copy is running must not wipe the Pro copy's
		// tables.
		if ( self::other_build_active() ) {
			return;
		}

		self::cleanup();
	}

	/**
	 * Is the other build (free or premium) of this plugin still active?
	 *
	 * Freemius names the premium folder `<slug>-premium` (get_premium_slug(),
	 * class-freemius.php:10005), so the two basenames are:
	 *
	 *   upgrade-pilot/upgrade-pilot.php
	 *   upgrade-pilot-premium/upgrade-pilot.php
	 *
	 * @return bool
	 */
	private static function other_build_active() {
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$me = defined( 'UPILOT_BASENAME' ) ? UPILOT_BASENAME : 'upgrade-pilot/upgrade-pilot.php';

		foreach ( array( 'upgrade-pilot/upgrade-pilot.php', 'upgrade-pilot-premium/upgrade-pilot.php' ) as $basename ) {
			if ( $basename === $me ) {
				continue; // That is the one being removed.
			}
			if ( is_plugin_active( $basename ) || is_plugin_active_for_network( $basename ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Remove every trace of Upgrade Pilot.
	 *
	 * MULTISITE: state is network-scoped (see UPilot_Store), so most of this is
	 * network-level. But two things still live per subsite and would be orphaned by
	 * a naive sweep: the scanner lock (deliberately kept in the MAIN SITE's
	 * wp_options, because wp_sitemeta has no unique index and so cannot host an
	 * atomic lock), and scheduled events, because WP-Cron is per-site. So every
	 * subsite is swept as well as the network.
	 *
	 * Idempotent: safe to call twice.
	 */
	public static function cleanup() {
		global $wpdb;

		if ( self::$done ) {
			return;
		}
		self::$done = true;

		$options = self::options();
		$hooks   = self::hooks();

		if ( is_multisite() ) {
			// 1. Network-scoped state.
			foreach ( $options as $option ) {
				delete_site_option( $option );
			}
			$wpdb->query( "DELETE FROM {$wpdb->sitemeta} WHERE meta_key LIKE '\_site\_transient\_upilot\_%' OR meta_key LIKE '\_site\_transient\_timeout\_upilot\_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- transient sweep at uninstall; no cache layer here.

			// 2. Every subsite: its own options, transients and cron jobs.
			$offset = 0;
			do {
				$batch = get_sites(
					array(
						'fields'                 => 'ids',
						'number'                 => 200,
						'offset'                 => $offset,
						'update_site_cache'      => false,
						'update_site_meta_cache' => false,
						'orderby'                => 'id',
					)
				);
				foreach ( (array) $batch as $blog_id ) {
					switch_to_blog( (int) $blog_id );
					self::clean_current_site( $options, $hooks );
					restore_current_blog();
				}
				$offset += 200;
			} while ( count( $batch ) === 200 );

			$prefix = $wpdb->base_prefix . 'upilot_';
		} else {
			self::clean_current_site( $options, $hooks );
			$prefix = $wpdb->prefix . 'upilot_';
		}

		// 3. The tables.
		foreach ( array( 'snapshots', 'events', 'scan_queue', 'findings', 'site_status' ) as $table ) {
			$wpdb->query( 'DROP TABLE IF EXISTS `' . $prefix . $table . '`' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- fixed whitelist of our own table names.
		}
	}

	/**
	 * Everything that lives inside ONE site's tables.
	 *
	 * @param string[] $options Option names.
	 * @param string[] $hooks   Cron hooks.
	 */
	private static function clean_current_site( $options, $hooks ) {
		global $wpdb;

		foreach ( $options as $option ) {
			delete_option( $option );
		}
		foreach ( $hooks as $hook ) {
			wp_clear_scheduled_hook( $hook );
		}
		$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\_transient\_upilot\_%' OR option_name LIKE '\_transient\_timeout\_upilot\_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- transient sweep at uninstall.
	}
}
