<?php
/**
 * This file is part of Avatar Privacy.
 *
 * Copyright 2018-2023 Peter Putzer.
 * Copyright 2012-2013 Johannes Freudendahl.
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 *  ***
 *
 * @package mundschenk-at/avatar-privacy
 * @license http://www.gnu.org/licenses/gpl-2.0.html
 */

namespace Avatar_Privacy\Components;

use Avatar_Privacy\Component;

use Avatar_Privacy\Core\Comment_Author_Fields;
use Avatar_Privacy\Core\Settings;
use Avatar_Privacy\Core\User_Fields;

use Avatar_Privacy\Components\Image_Proxy;

use Avatar_Privacy\Data_Storage\Database\Hashes_Table;
use Avatar_Privacy\Data_Storage\Database\Table; // phpcs:ignore ImportDetection.Imports.RequireImports.Import -- needed for type annotations.
use Avatar_Privacy\Data_Storage\Network_Options;
use Avatar_Privacy\Data_Storage\Options;
use Avatar_Privacy\Data_Storage\Site_Transients;
use Avatar_Privacy\Data_Storage\Transients;

use Avatar_Privacy\Tools\Multisite;

/**
 * Handles plugin activation and deactivation.
 *
 * @since 1.0.0
 *
 * @author Peter Putzer <github@mundschenk.at>
 *
 * @phpstan-import-type SettingsFields from \Avatar_Privacy\Core\Settings
 *
 * @phpstan-type ObsoleteSettingsFields array<self::OBSOLETE_SETTINGS_FILES, mixed>
 */
class Setup implements Component {

	/**
	 * Obsolete settings keys.
	 *
	 * @var string[]
	 */
	const OBSOLETE_SETTINGS = [
		'mode_optin',
		'use_gravatar',
		'mode_checkforgravatar',
		'default_show',
		'checkbox_default',
	];

	/**
	 * Obsolete avatar defaults and replacement values.
	 *
	 * @var string[]
	 */
	const OBSOLETE_AVATAR_DEFAULTS = [
		'comment'           => 'bubble',
		'im-user-offline'   => 'bowling-pin',
		'view-media-artist' => 'silhouette',
	];

	/**
	 * The options handler.
	 *
	 * @var Options
	 */
	private Options $options;

	/**
	 * The options handler.
	 *
	 * @var Network_Options
	 */
	private Network_Options $network_options;

	/**
	 * The transients handler.
	 *
	 * @var Transients
	 */
	private Transients $transients;

	/**
	 * The site transients handler.
	 *
	 * @var Site_Transients
	 */
	private Site_Transients $site_transients;

	/**
	 * The database table handlers.
	 *
	 * @var Table[]
	 */
	private array $tables;

	/**
	 * The multisite tools.
	 *
	 * @var Multisite
	 */
	private Multisite $multisite;

	/**
	 * The settings API.
	 *
	 * @since 2.4.0
	 *
	 * @var Settings
	 */
	private Settings $settings;

	/**
	 * The user fields API.
	 *
	 * @since 2.4.0
	 *
	 * @var User_Fields
	 */
	private User_Fields $registered_user;

	/**
	 * The comment author fields API.
	 *
	 * @since 2.4.0
	 *
	 * @var Comment_Author_Fields
	 */
	private Comment_Author_Fields $comment_author;

	/**
	 * Creates a new Setup instance.
	 *
	 * @since 2.1.0 Parameter $plugin_file removed.
	 * @since 2.4.0 Parameters $settings, $registered_user, $comment_author, and
	 *              $tables added, parameters $core and $database removed.
	 *
	 * @param Settings              $settings        The settings API.
	 * @param User_Fields           $registered_user The user fields API.
	 * @param Comment_Author_Fields $comment_author  The comment author fields API.
	 * @param Transients            $transients      The transients handler.
	 * @param Site_Transients       $site_transients The site transients handler.
	 * @param Options               $options         The options handler.
	 * @param Network_Options       $network_options The network options handler.
	 * @param Table[]               $tables          The database table handlers, indexed by their base name.
	 * @param Multisite             $multisite       The the multisite handler.
	 */
	public function __construct( Settings $settings, User_Fields $registered_user, Comment_Author_Fields $comment_author, Transients $transients, Site_Transients $site_transients, Options $options, Network_Options $network_options, array $tables, Multisite $multisite ) {
		$this->settings        = $settings;
		$this->registered_user = $registered_user;
		$this->comment_author  = $comment_author;
		$this->transients      = $transients;
		$this->site_transients = $site_transients;
		$this->options         = $options;
		$this->network_options = $network_options;
		$this->tables          = $tables;
		$this->multisite       = $multisite;
	}

	/**
	 * Sets up the various hooks for the plugin component.
	 *
	 * @return void
	 */
	public function run() {
		// Register deactivation hook. Activation is handled by the update check instead.
		\register_deactivation_hook( \AVATAR_PRIVACY_PLUGIN_FILE, [ $this, 'deactivate' ] );

		// Update settings and database if necessary.
		\add_action( 'plugins_loaded', [ $this, 'update_check' ] );

		// Clean up uploaded files after deleting the refering metadata.
		\add_action( 'deleted_user_meta', [ $this->registered_user, 'remove_orphaned_local_avatar' ], 10, 4 );
	}

	/**
	 * Checks if the default settings or database schema need to be upgraded.
	 *
	 * @return void
	 */
	public function update_check() {
		// Force reading the settings from the DB, but do not cache the result.
		$current_settings = $this->settings->get_all_settings( true );

		// We can ignore errors here, just carry on as if for a new installation.
		if ( ! empty( $current_settings[ Options::INSTALLED_VERSION ] ) ) {
			$installed_version = $current_settings[ Options::INSTALLED_VERSION ];
		} elseif ( ! empty( $current_settings ) && ! isset( $current_settings[ Options::INSTALLED_VERSION ] ) ) {
			// Plugin releases before 1.0 did not store the installed version.
			$installed_version = '0.4-or-earlier';
		} else {
			// The plugins was not installed previously.
			$installed_version = '';
		}

		// Check if the plugin data needs to be updated.
		$version       = $this->settings->get_version();
		$update_needed = $version !== $installed_version;
		$new_install   = empty( $installed_version );

		if ( $update_needed ) {
			if ( ! $new_install ) {
				// Update plugin settings if necessary.
				$current_settings = $this->update_settings( $installed_version, $current_settings );

				// Preserve previous multisite behavior.
				// This needs to happen before the database tables are set up.
				if ( \is_multisite() && \version_compare( $installed_version, '0.5', '<' ) ) {
					$this->network_options->set( Network_Options::USE_GLOBAL_TABLE, true );
				}
			}

			// Clear transients.
			$this->transients->invalidate();
			$this->site_transients->invalidate();

			// To be safe, let's always flush the rewrite rules if there has been an update.
			$this->flush_rewrite_rules_soon();
		}

		// Check if our database tables need to created or updated.
		// This also sets up the `$wpdb->avatar_privacy*` properties, so we have
		// to check on every page load.
		foreach ( $this->tables as $table ) {
			$table->setup( $installed_version );
		}

		// Run additional upgrade routines now that the tables are set up, but
		// only if this plugin has been previously installed.
		if ( $update_needed && ! $new_install ) {
			$this->update_plugin_data( $installed_version );
		}

		// Update installed version.
		$current_settings[ Options::INSTALLED_VERSION ] = $version;
		$this->options->set( Settings::OPTION_NAME, $current_settings );
	}

	/**
	 * Updates the plugin settings.
	 *
	 * @since 2.1.0 Visibility changed to protected.
	 * @since 2.4.0 Renamed to update_settings. Parameter $settings passed by value
	 *              and returned as the result of of the function.
	 *
	 * @param  string $previous_version The version we are upgrading from.
	 * @param  array  $settings         The settings array.
	 *
	 * @return array                    The updated settings array.
	 *
	 * @phpstan-param ObsoleteSettingsFields $settings
	 * @phpstan-return mixed[]
	 */
	protected function update_settings( $previous_version, array $settings ) {
		// Upgrade from version 0.4 or lower.
		if ( \version_compare( $previous_version, '0.5', '<' ) ) {
			// Drop old settings.
			foreach ( self::OBSOLETE_SETTINGS as $key ) {
				unset( $settings[ $key ] );
			}
		}

		return $settings;
	}

	/**
	 * Upgrades existing plugin data.
	 *
	 * @since 2.4.0
	 *
	 * @param string $previous_version The version we are upgrading from.
	 *
	 * @return void
	 */
	protected function update_plugin_data( $previous_version ) {
		// Upgrade from version 0.4 or lower.
		if ( \version_compare( $previous_version, '0.5', '<' ) ) {
			$this->maybe_update_user_hashes();
		}

		// Upgrade from anything below 1.0-rc.1.
		if ( \version_compare( $previous_version, '1.0-rc.1', '<' ) ) {
			$this->upgrade_old_avatar_defaults();
		}

		// Upgrade from anything below 2.1.0-alpha.3.
		if ( \version_compare( $previous_version, '2.1.0-alpha.3', '<' ) ) {
			$this->prefix_usermeta_keys();
		}

		// Upgrade from anything below 2.4.0.
		if ( \version_compare( $previous_version, '2.4.0', '<' ) ) {
			$this->maybe_add_email_hashes();
		}
	}

	/**
	 * Handles plugin deactivation.
	 *
	 * @since 2.1.0 Parameter `$network_wide` added.
	 *
	 * @param  bool $network_wide A flag indicating if the plugin was network-activated.
	 *
	 * @return void
	 */
	public function deactivate( $network_wide ) {
		if ( ! $network_wide ) {
			// We've only been activated on this site, all good.
			$this->deactivate_plugin();
		} elseif ( ! \wp_is_large_network() ) {
			// This is a "small" multisite network, so get WordPress to rebuild the rewrite rules.
			$this->multisite->do_for_all_sites_in_network( [ $this, 'deactivate_plugin' ] );
		} else {
			// OK, let's try not to break anything.
			$this->multisite->do_for_all_sites_in_network(
				// We still need to disable our cron jobs, though.
				function() {
					\wp_unschedule_hook( Image_Proxy::CRON_JOB_ACTION );
				}
			);
		}
	}

	/**
	 * Triggers a rebuild of the rewrite rules on the next page load.
	 *
	 * @since 2.1.0
	 *
	 * @return void
	 */
	public function flush_rewrite_rules_soon() {
		// Deleting the option forces a rebuild in the proper context on the next load.
		$this->options->delete( 'rewrite_rules', true );
	}

	/**
	 * Adds a prefix to the GRAVATAR_USE_META_KEY.
	 *
	 * This migration method will not work if the standard `wp_usermeta` table is
	 * replaced with something else, but there does not seem to be a good way to
	 * use the `get_user_metadata` filter hook and fulfill the goal of not breaking
	 * future Core use of `use_gravatar` as a meta key.
	 *
	 * @since 2.1.0
	 *
	 * @global wpdb $wpdb The WordPress Database Access Abstraction.
	 *
	 * @return void
	 */
	public function prefix_usermeta_keys() {
		global $wpdb;

		// Get all users with the `use_gravatar` meta key.
		$affected_users = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", 'use_gravatar' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( \count( $affected_users ) > 0 ) {
			// Update the database table.
			$rows = $wpdb->update( $wpdb->usermeta, [ 'meta_key' => User_Fields::GRAVATAR_USE_META_KEY ], [ 'meta_key' => 'use_gravatar' ] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

			// If there were any keys to update, we also have to clear the user_meta cache group.
			if ( false !== $rows && $rows > 0 ) {

				// Clear user_meta cache for all affected users.
				foreach ( $affected_users as $user_id ) {
					\wp_cache_delete( $user_id, 'user_meta' );
				}
			}
		}
	}

	/**
	 * The deactivation tasks for a single site.
	 *
	 * @since 2.1.0
	 *
	 * @return void
	 */
	public function deactivate_plugin() {
		// Disable cron jobs.
		\wp_unschedule_hook( Image_Proxy::CRON_JOB_ACTION );

		// Reset avatar defaults.
		$this->options->reset_avatar_default();

		// Flush rewrite rules on next page load.
		$this->flush_rewrite_rules_soon();
	}

	/**
	 * Tries to upgrade the `avatar_defaults` option.
	 *
	 * @since 2.1.0 Visibility changed to protected, $options parameter removed.
	 *
	 * @return void
	 */
	protected function upgrade_old_avatar_defaults() {
		$obsolete_avatar_defaults = self::OBSOLETE_AVATAR_DEFAULTS;
		$old_default              = $this->options->get( 'avatar_default', 'mystery', true );

		if ( ! empty( $obsolete_avatar_defaults[ $old_default ] ) ) {
			$this->options->set( 'avatar_default', $obsolete_avatar_defaults[ $old_default ], true, true );
		}
	}

	/**
	 * Updates user hashes where they don't exist yet.
	 *
	 * @since 2.1.0 Visibility changed to protected.
	 *
	 * @return void
	 */
	protected function maybe_update_user_hashes() {
		$args = [
			'meta_key'     => User_Fields::EMAIL_HASH_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
			'meta_compare' => 'NOT EXISTS',
		];

		foreach ( \get_users( $args ) as $user ) {
			// Ensure that there is a user hash - retrieving the hash updates the meta field.
			$this->registered_user->get_hash( $user->ID );
		}
	}

	/**
	 * Adds hashes for stored e-mail addresses if necessary.
	 *
	 * @since 2.4.0
	 *
	 * @global \wpdb  $wpdb The WordPress Database Access Abstraction.
	 *
	 * @return int          The number of upgraded rows.
	 */
	protected function maybe_add_email_hashes() {
		global $wpdb;

		// Add hashes when they are missing.
		$emails = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- DB and column name.
				'SELECT c.email FROM `%1$s` c LEFT OUTER JOIN `%2$s` h ON c.email = h.identifier AND h.type = "comment" AND h.hash IS NULL',
				$wpdb->avatar_privacy,
				$wpdb->avatar_privacy_hashes
			)
		);
		$email_count = \count( $emails );

		if ( $email_count > 0 ) {
			// Add hashes for all retrieved rows.
			$rows = [];
			foreach ( $emails as $email ) {
				$rows[] = [
					'identifier' => $email,
					'hash'       => $this->comment_author->get_hash( $email ),
					'type'       => 'comment',
				];
			}

			return (int) $this->tables[ Hashes_Table::TABLE_BASENAME ]->insert_or_update( [ 'identifier', 'hash', 'type' ], $rows );
		}

		return 0;
	}
}
