<?php
/**
 * ###                    ###
 * ###   HEY DEVELOPER!   ###
 * ###                    ###
 * ###  (read me first)   ###
 *
 * Thanks for integrating TrustedLogin.
 *
 * 0. If you haven't already, sign up for a TrustedLogin account {@see https://www.trustedlogin.com}
 * 1. Namespace the installation ({@see https://www.trustedlogin.com/configuration/} to learn how)
 * 2. Instantiate this class with a configuration object (really, go see {@see https://www.trustedlogin.com/configuration/} for more info)
 *
 * Class Client
 *
 * @package GOSP\TrustedLogin\Client
 *
 * @copyright 2023 Katz Web Services, Inc.
 */

namespace GOSP\TrustedLogin;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Exception;
use WP_Error;

/**
 * The TrustedLogin all-in-one drop-in class.
 */
final class Client {

	/**
	 * The current SDK version.
	 *
	 * @var string The current SDK version.
	 * @since 1.0.0
	 */
	const VERSION = '1.10.1';

	/**
	 * Instance of Config
	 *
	 * @var Config
	 */
	private $config;

	/**
	 * Whether the configuration is valid.
	 *
	 * @var bool True if the configuration is valid; false if not.
	 */
	public static $valid_config;

	/**
	 * Instance of Logging
	 *
	 * @var null|Logging $logging
	 */
	private $logging;

	/**
	 * Instance of SupportUser
	 *
	 * @var SupportUser $support_user
	 */
	private $support_user;

	/**
	 * Instance of Remote
	 *
	 * @var Remote $remote
	 */
	private $remote;

	/**
	 * Instance of Cron
	 *
	 * @var Cron $cron
	 */
	private $cron;

	/**
	 * Instance of Endpoint
	 *
	 * @var Endpoint $endpoint
	 */
	private $endpoint;

	/**
	 * Instance of Admin
	 *
	 * @var Admin $admin
	 */
	private $admin;

	/**
	 * Instance of Ajax
	 *
	 * @var Ajax
	 */
	private $ajax;

	/**
	 * Instance of SiteAccess
	 *
	 * @var SiteAccess $site_access
	 */
	private $site_access;


	/**
	 * TrustedLogin constructor.
	 *
	 * @see https://docs.trustedlogin.com/ for more information.
	 *
	 * @param Config $config The configuration object.
	 * @param bool   $init Whether to initialize everything on instantiation.
	 *
	 * @throws Exception If initializing is prevented via constants or the configuration isn't valid, throws exception.
	 *
	 * @returns void If no errors, returns void. Otherwise, throws exceptions.
	 */
	public function __construct( Config $config, $init = true ) {

		$should_initialize = $this->should_init( $config );

		if ( is_wp_error( $should_initialize ) ) {
			throw new Exception( esc_html( $should_initialize->get_error_message() ), 403 );
		}

		try {
			self::$valid_config = $config->validate();
		} catch ( Exception $exception ) {
			self::$valid_config = false;
			throw $exception;
		}

		$this->config = $config;

		$this->logging = new Logging( $config );

		$this->cron = new Cron( $this->config, $this->logging );

		$this->support_user = new SupportUser( $this->config, $this->logging );

		$this->site_access = new SiteAccess( $this->config, $this->logging );

		// Remote / LoginAttempts must be built BEFORE Endpoint —
		// Endpoint::fail_login delegates to LoginAttempts on the
		// login_failed branch, and LoginAttempts wraps Remote.
		$this->remote = new Remote( $this->config, $this->logging );

		$login_attempts = new LoginAttempts( $this->config, $this->remote, $this->logging );

		$this->endpoint = new Endpoint( $this->config, $this->logging, $login_attempts, $this->support_user );

		$form = new Form( $this->config, $this->logging, $this->support_user, $this->site_access );

		$this->admin = new Admin( $this->config, $form, $this->support_user );

		$this->ajax = new Ajax( $this->config, $this->logging, $this );

		if ( $init ) {
			$this->init();
		}
	}

	/**
	 * Should the Client fully initialize?
	 *
	 * @param Config $config The configuration object.
	 *
	 * @return true|WP_Error
	 */
	private function should_init( Config $config ) {

		// Disables all TL clients for the site.
		if ( defined( 'TRUSTEDLOGIN_DISABLE' ) && TRUSTEDLOGIN_DISABLE ) {
			return new WP_Error( 'disabled_globally', 'TrustedLogin has been disabled globally for this site using the TRUSTEDLOGIN_DISABLE constant.' );
		}

		$ns = $config->ns();

		// Namespace isn't set; allow Config.
		if ( empty( $ns ) ) {
			return true;
		}

		// Disables namespaced client if `TRUSTEDLOGIN_DISABLE_{NS}` is defined and truthy.
		if ( defined( 'TRUSTEDLOGIN_DISABLE_' . strtoupper( $ns ) ) && constant( 'TRUSTEDLOGIN_DISABLE_' . strtoupper( $ns ) ) ) {
			return new WP_Error( 'disabled_for_namespace', 'TrustedLogin has been disabled for this namespace using the TRUSTEDLOGIN_DISABLE_' . $ns . ' constant.' );
		}

		$meets_requirements = Encryption::meets_requirements();

		if ( ! $meets_requirements ) {
			return new WP_Error( 'does_not_meet_requirements', 'TrustedLogin could not load: the site does not meet encryption requirements.' );
		}

		return true;
	}

	/**
	 * Initialize all the things!
	 */
	public function init() {
		$this->admin->init();
		$this->endpoint->init();
		$this->remote->init();
		$this->cron->init();
		$this->ajax->init();
	}

	/**
	 * Returns the current access key (hashed license key or generated access key
	 *
	 * @see SiteAccess::get_access_key()
	 *
	 * @return string|null|WP_Error
	 */
	public function get_access_key() {

		if ( ! self::$valid_config ) {
			return new WP_Error( 'invalid_configuration', 'TrustedLogin has not been properly configured or instantiated.', array( 'error_code' => 424 ) );
		}

		return $this->site_access->get_access_key();
	}

	/**
	 * Creates a TrustedLogin support user.
	 *
	 * @since 1.5.0 Added $ticket_data parameter.
	 *
	 * @param bool       $include_debug_data Whether to include debug data in the response.
	 * @param array|null $ticket_data If provided, customer-provided data associated with the access request.
	 *
	 * @return array|WP_Error
	 */
	public function grant_access( $include_debug_data = false, $ticket_data = null ) {

		if ( ! self::$valid_config ) {
			return new WP_Error( 'invalid_configuration', 'TrustedLogin has not been properly configured or instantiated.', array( 'error_code' => 424 ) );
		}

		if ( ! current_user_can( 'create_users' ) ) {
			return new WP_Error( 'no_cap_create_users', 'Permissions issue: You do not have the ability to create users.', array( 'error_code' => 403 ) );
		}

		$user_id = $this->support_user->exists();

		// If the user exists already, extend access.
		if ( $user_id ) {
			return $this->extend_access( $user_id );
		}

		// Check SSL before creating a support user. Creating locally and
		// then failing the SaaS sync on the SSL gate would orphan the
		// user — it can never be used to log in because no envelope
		// reached the vendor dashboard. Fail-fast keeps state clean.
		if ( ! $this->config->meets_ssl_requirement() ) {
			return new WP_Error( 'fails_ssl_requirement', esc_html__( 'Support access requires a secure (HTTPS) connection. Please enable HTTPS on this site and try again.', 'trustedlogin' ), array( 'error_code' => 426 ) );
		}

		timer_start();

		try {
			$support_user_id = $this->support_user->create();
		} catch ( Exception $exception ) {
			$this->logging->log( 'An exception occurred trying to create a support user.', __METHOD__, 'critical', $exception );

			return new WP_Error( 'support_user_exception', $exception->getMessage(), array( 'error_code' => 500 ) );
		}

		if ( is_wp_error( $support_user_id ) ) {
			$this->logging->log( sprintf( 'Support user not created: %s (%s)', $support_user_id->get_error_message(), $support_user_id->get_error_code() ), __METHOD__, 'error' );

			$support_user_id->add_data( array( 'error_code' => 409 ) );

			return $support_user_id;
		}

		$site_identifier_hash = Encryption::get_random_hash( $this->logging );

		if ( is_wp_error( $site_identifier_hash ) ) {
			$this->delete_unsynced_support_user( $support_user_id );

			$this->logging->log( 'Could not generate a secure secret.', __METHOD__, 'error' );

			return new WP_Error( 'secure_secret_failed', 'Could not generate a secure secret.', array( 'error_code' => 501 ) );
		}

		$endpoint_hash = $this->endpoint->get_hash( $site_identifier_hash );

		$updated = $this->endpoint->update( $endpoint_hash );

		if ( ! $updated ) {
			$this->logging->log( 'Endpoint hash did not save or didn\'t update.', __METHOD__, 'info' );
		}

		$expiration_timestamp = $this->config->get_expiration_timestamp();

		// Add user meta, configure decay.
		$did_setup = $this->support_user->setup( $support_user_id, $site_identifier_hash, $expiration_timestamp, $this->cron );

		if ( is_wp_error( $did_setup ) ) {
			$this->delete_unsynced_support_user( $support_user_id );

			$did_setup->add_data( array( 'error_code' => 503 ) );

			return $did_setup;
		}

		if ( empty( $did_setup ) ) {
			return new WP_Error( 'support_user_setup_failed', 'Error updating user with identifier.', array( 'error_code' => 503 ) );
		}

		$secret_id = $this->endpoint->generate_secret_id( $site_identifier_hash, $endpoint_hash );

		if ( is_wp_error( $secret_id ) ) {
			$this->delete_unsynced_support_user( $support_user_id );

			$secret_id->add_data( array( 'error_code' => 500 ) );

			return $secret_id;
		}

		$reference_id = self::get_reference_id();

		$timing_local = timer_stop( 0, 5 );

		$return_data = array(
			'type'         => 'new',
			'key'          => $this->site_access->get_access_key(),
			'site_url'     => get_site_url(),
			'endpoint'     => $endpoint_hash,
			'identifier'   => $site_identifier_hash,
			'user_id'      => $support_user_id,
			'expiry'       => $expiration_timestamp,
			'reference_id' => $reference_id,
			'timing'       => array(
				'local'  => $timing_local,
				'remote' => null, // Updated later.
			),
		);

		timer_start();

		try {
			add_filter(
				'trustedlogin/' . $this->config->ns() . '/envelope/meta',
				array(
					$this,
					'add_meta_to_envelope',
				)
			);

			$created = $this->site_access->sync_secret( $secret_id, $site_identifier_hash, 'create' );

			remove_filter(
				'trustedlogin/' . $this->config->ns() . '/envelope/meta',
				array(
					$this,
					'add_meta_to_envelope',
				)
			);
		} catch ( Exception $e ) {
			$exception_error = new WP_Error( $e->getCode(), $e->getMessage(), array( 'status_code' => 500 ) );

			$this->logging->log( 'There was an error creating a secret.', __METHOD__, 'error', $e );

			$this->delete_unsynced_support_user( $support_user_id );

			return $exception_error;
		}

		if ( is_wp_error( $created ) ) {

			// get_all_error_data() is only available in WP 5.6+. Fallback to get_error_data().
			$error_data = is_callable( array( $created, 'get_all_error_data' ) ) ? $created->get_all_error_data() : $created->get_error_data();

			$this->logging->log( sprintf( 'There was an issue creating access (%s): %s', $created->get_error_code(), $created->get_error_message() ), __METHOD__, 'error', $error_data );

			$created->add_data( array( 'status_code' => 503 ) );

			$this->delete_unsynced_support_user( $support_user_id );

			return $created;
		}

		$return_data['timing']['remote'] = timer_stop( 0, 5 );

		timer_start();

		$action_data = array(
			'url'        => get_site_url(),
			'ns'         => $this->config->ns(),
			'action'     => 'created',
			'ref'        => $reference_id,
			'access_key' => $this->site_access->get_access_key(),
		);

		if ( $include_debug_data ) {
			$action_data['debug_data'] = $this->get_debug_data();
		}

		if ( $ticket_data ) {
			$action_data['ticket'] = $ticket_data;
		}

		/**
		 * Action performed when access is created.
		 *
		 * @param array $action_data{url:string,ns:string,action:string,ref:string,access_key:string} Array of data about the action.
		 *
		 * @usedby Remote::maybe_send_webhook()
		 */
		do_action( 'trustedlogin/' . $this->config->ns() . '/access/created', $action_data );

		$return_data['timing']['access_created_action'] = timer_stop( 0, 5 );

		return $return_data;
	}

	/**
	 * Extends the access duration for an existing Support User
	 *
	 * @since 1.0.0
	 *
	 * @param int $user_id The existing Support User ID.
	 *
	 * @return array|WP_Error
	 */
	private function extend_access( $user_id ) {

		// Check SSL BEFORE any local mutation. If we extend the user's
		// expiration first and only then notice we can't reach the SaaS,
		// local + remote state diverge: the site believes the agent's
		// access is extended, but the envelope the agent needs to log
		// in via the vendor dashboard still reflects the old expiration.
		// Fail fast so the customer is asked to enable HTTPS and retry.
		if ( ! $this->config->meets_ssl_requirement() ) {
			return new WP_Error( 'fails_ssl_requirement', esc_html__( 'Support access requires a secure (HTTPS) connection. Please enable HTTPS on this site and try again.', 'trustedlogin' ), array( 'error_code' => 426 ) );
		}

		timer_start();

		$expiration_timestamp = $this->config->get_expiration_timestamp();

		$site_identifier_hash = $this->support_user->get_site_hash( $user_id );

		if ( is_wp_error( $site_identifier_hash ) ) {
			$this->logging->log( sprintf( 'Could not get identifier hash for existing support user account. %s (%s)', $site_identifier_hash->get_error_message(), $site_identifier_hash->get_error_code() ), __METHOD__, 'critical' );

			return $site_identifier_hash;
		}

		$extended = $this->support_user->extend( $user_id, $site_identifier_hash, $expiration_timestamp, $this->cron );

		if ( is_wp_error( $extended ) ) {
			return $extended;
		}

		$secret_id = $this->endpoint->generate_secret_id( $site_identifier_hash );

		if ( is_wp_error( $secret_id ) ) {
			wp_delete_user( $user_id );

			$secret_id->add_data( array( 'error_code' => 500 ) );

			return $secret_id;
		}

		$timing_local = timer_stop( 0, 5 );

		$return_data = array(
			'type'       => 'extend',
			'site_url'   => get_site_url(),
			'identifier' => $site_identifier_hash,
			'user_id'    => $user_id,
			'expiry'     => $expiration_timestamp,
			'timing'     => array(
				'local'  => $timing_local,
				'remote' => null, // Updated later.
			),
		);

		timer_start();

		try {
			add_filter(
				'trustedlogin/' . $this->config->ns() . '/envelope/meta',
				array(
					$this,
					'add_meta_to_envelope',
				)
			);

			$updated = $this->site_access->sync_secret( $secret_id, $site_identifier_hash, 'extend' );

			remove_filter(
				'trustedlogin/' . $this->config->ns() . '/envelope/meta',
				array(
					$this,
					'add_meta_to_envelope',
				)
			);
		} catch ( Exception $e ) {
			$exception_error = new WP_Error( $e->getCode(), $e->getMessage(), array( 'status_code' => 500 ) );

			$this->logging->log( 'There was an error updating TrustedLogin servers.', __METHOD__, 'error', $e );

			wp_delete_user( $user_id );

			return $exception_error;
		}

		if ( is_wp_error( $updated ) ) {
			$this->logging->log( sprintf( 'There was an issue creating access (%s): %s', $updated->get_error_code(), $updated->get_error_message() ), __METHOD__, 'error' );

			$updated->add_data( array( 'status_code' => 503 ) );

			wp_delete_user( $user_id );

			return $updated;
		}

		$return_data['timing']['remote'] = timer_stop( 0, 5 );

		/**
		 * Action performed when access is extended.
		 *
		 * @used-by Remote::maybe_send_webhook()
		 */
		do_action(
			'trustedlogin/' . $this->config->ns() . '/access/extended',
			array(
				'url'        => get_site_url(),
				'ns'         => $this->config->ns(),
				'action'     => 'extended',
				'ref'        => self::get_reference_id(),
				'access_key' => $this->site_access->get_access_key(),
			)
		);

		return $return_data;
	}

	/**
	 * Revoke access to a site
	 *
	 * @param string $identifier Unique ID or "all".
	 *
	 * @return bool|WP_Error True: Synced to SaaS and user(s) deleted. False: empty identifier. WP_Error: failed to revoke site in SaaS or failed to delete user.
	 */
	public function revoke_access( $identifier = '' ) {

		if ( empty( $identifier ) ) {
			$this->logging->log( 'Missing the revoke access identifier.', __METHOD__, 'error' );

			return false;
		}

		if ( 'all' === $identifier ) {
			$users = $this->support_user->get_all();

			foreach ( $users as $user ) {
				$user_identifier = $this->support_user->get_user_identifier( $user );

				// Errors are already logged in the get_user_identifier() method.
				if ( is_wp_error( $user_identifier ) ) {
					continue;
				}

				$this->revoke_access( $user_identifier );
			}

			// Terminal return for the aggregate "all" branch so the
			// function doesn't fall through to $this->support_user->get( 'all' )
			// which would (correctly) return null and cause a misleading
			// "User does not exist" log + missing revoked webhook summary.
			return true;
		}

		$user = $this->support_user->get( $identifier );

		if ( null === $user ) {
			$this->logging->log( 'User does not exist; access may have already been revoked.', __METHOD__, 'error' );

			return false;
		}

		$site_identifier_hash = $this->support_user->get_site_hash( $user );

		if ( is_wp_error( $site_identifier_hash ) ) {
			$this->logging->log( 'Could not get identifier hash for existing support user account.', __METHOD__, 'error' );

			return $site_identifier_hash;
		}

		$endpoint_hash = $this->endpoint->get_hash( $site_identifier_hash );

		if ( is_wp_error( $endpoint_hash ) ) {
			$this->logging->log( 'Could not get endpoint hash for existing support user account.', __METHOD__, 'error' );

			return $endpoint_hash;
		}

		$secret_id = $this->endpoint->generate_secret_id( $site_identifier_hash, $endpoint_hash );

		if ( is_wp_error( $secret_id ) ) {
			$this->logging->log( 'Could not generate a secure secret.', __METHOD__, 'error' );

			return $secret_id;
		}

		// Revoke site in SaaS. The result is advisory — local cleanup
		// continues regardless, and a failed sync gets queued for the
		// SaaS-revoke retry cron so the site eventually drops from SaaS.
		$site_revoked = $this->site_access->revoke( $secret_id, $this->remote );

		if ( is_wp_error( $site_revoked ) ) {
			$this->logging->log(
				'SaaS revoke sync failed (' . $site_revoked->get_error_code() . '). Queueing retry; local revoke will continue.',
				__METHOD__,
				'error'
			);
			$this->cron->queue_saas_revoke_retry( $secret_id );
		}

		$deleted_user = $this->support_user->delete( $identifier, true, true );

		if ( is_wp_error( $deleted_user ) ) {
			$this->logging->log( 'Removing user failed: ' . $deleted_user->get_error_message(), __METHOD__, 'error' );

			return $deleted_user;
		}

		$should_be_deleted = $this->support_user->get( $identifier );

		if ( ! empty( $should_be_deleted ) ) {
			$this->logging->log( 'User #' . $should_be_deleted->ID . ' was not removed', __METHOD__, 'error' );

			return new WP_Error( 'support_user_not_deleted', esc_html__( 'The support user was not deleted.', 'trustedlogin' ) );
		}

		/**
		 * Local cleanup succeeded. SaaS sync is either done already or
		 * queued in the retry cron.
		 */
		do_action(
			'trustedlogin/' . $this->config->ns() . '/access/revoked',
			array(
				'url'    => get_site_url(),
				'ns'     => $this->config->ns(),
				'action' => 'revoked',
			)
		);

		return true;
	}

	/**
	 * Retry an outstanding SaaS revoke for a single secret_id. Called from
	 * {@see Cron::retry_saas_revoke()} when the initial sync failed.
	 *
	 * @since 1.10.0
	 *
	 * @param string $secret_id Site secret identifier.
	 *
	 * @return bool True on success, false if the SaaS sync still failed.
	 */
	public function retry_saas_revoke( $secret_id ) {
		if ( ! is_string( $secret_id ) || '' === $secret_id ) {
			return false;
		}

		$result = $this->site_access->revoke( $secret_id, $this->remote );

		return ! is_wp_error( $result );
	}

	/**
	 * Adds PLAINTEXT metadata to the envelope, including reference ID.
	 *
	 * @since 1.0.0
	 *
	 * @param array $metadata Array of metadata that will be sent UNENCRYPTED, PLAIN TEXT with the Envelope.
	 *
	 * @return array Array of metadata that will be sent with the Envelope.
	 */
	public function add_meta_to_envelope( $metadata = array() ) {

		$reference_id = self::get_reference_id();

		if ( $reference_id ) {
			$metadata['reference_id'] = $reference_id;
		}

		return $metadata;
	}

	/**
	 * Roll back a partially-created support user when the SaaS sync
	 * fails mid-grant. Plain wp_delete_user() leaves the user record
	 * in the network table on multisite — the user can no longer
	 * log in, but a row remains in wp_users with the cloned
	 * support-role caps in user_meta. wpmu_delete_user removes the
	 * record from the network entirely.
	 *
	 * @param int $support_user_id The user id to remove.
	 *
	 * @return void
	 */
	private function delete_unsynced_support_user( $support_user_id ) {
		require_once ABSPATH . 'wp-admin/includes/user.php';

		// wpmu_delete_user lives in wp-admin/includes/ms.php which isn\'t
		// auto-loaded outside network admin context. Without this load
		// the network user row leaks every time rollback fires from
		// admin_init / template_redirect.
		if ( is_multisite() ) {
			require_once ABSPATH . 'wp-admin/includes/ms.php';
		}

		wp_delete_user( $support_user_id );

		if ( is_multisite() && function_exists( 'wpmu_delete_user' ) ) {
			wpmu_delete_user( $support_user_id );
		}
	}

	/**
	 * Gets the reference ID passed to the request via POST or GET using `reference_id` or `ref` keys.
	 *
	 * @since 1.0.0
	 *
	 * @return string|null Sanitized reference ID (escaped with esc_html) if exists. NULL if not.
	 */
	public static function get_reference_id() {

		// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
		$reference_id = Utils::get_request_param( 'reference_id' );

		if ( $reference_id ) {
			return esc_html( $reference_id );
		}

		$ref = Utils::get_request_param( 'ref' );

		if ( $ref ) {
			return esc_html( $ref );
		}

		// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash

		return null;
	}

	/**
	 * Returns the debug data for the current website.
	 *
	 * @since 1.4.0
	 *
	 * @return string|false|null String: A text-formatted summary of WP Debug Data; false: the debug data setting wasn't enabled; null: there was an error.
	 */
	private function get_debug_data() {

		if ( ! $this->config->get_setting( 'webhook/debug_data' ) ) {
			return false;
		}

		if ( ! class_exists( 'WP_Debug_Data' ) ) {
			include_once ABSPATH . 'wp-admin/includes/class-wp-debug-data.php';
		}

		if ( ! class_exists( 'WP_Debug_Data' ) ) {
			$this->logging->log( 'WP_Debug_Data failed to be loaded.', __METHOD__, 'error' );

			return null;
		}

		try {
			$info = \WP_Debug_Data::debug_data();
		} catch ( \ImagickException $exception ) {
			unset( $exception ); // Imagick failure inside WP_Debug_Data — surface as null, no detail needed.
			return null;
		}

		$debug_data = \WP_Debug_Data::format( $info, 'info' );

		// Remove backtick added by WP.
		$debug_data = trim( $debug_data, '`' );

		// Format Markdown in Zapier-friendly manner (`### Heading`, not `### Heading ###`).
		$debug_data = str_replace( "###\n", "\n", $debug_data );

		// Add two spaces to create line breaks in Markdown.
		$debug_data = str_replace( "\n", "  \n", $debug_data );

		return $debug_data;
	}
}
