<?php

namespace Yoast\WP\SEO\MyYoast_Client\User_Interface;

use Exception;
use WP_CLI;
use WP_CLI\ExitException;
use Yoast\WP\SEO\Commands\Command_Interface;
use Yoast\WP\SEO\Conditionals\MyYoast_Connection_Conditional;
use Yoast\WP\SEO\General\User_Interface\General_Page_Integration;
use Yoast\WP\SEO\Loadable_Interface;
use Yoast\WP\SEO\Main;
use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client;
use Yoast\WP\SEO\MyYoast_Client\Application\MyYoast_Client_Cleanup;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Client_Registration_Interface;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\Token_Storage_Interface;
use Yoast\WP\SEO\MyYoast_Client\Application\Ports\User_Token_Storage_Interface;
use Yoast\WP\SEO\MyYoast_Client\Domain\Token_Set;
use Yoast\WP\SEO\MyYoast_Client\Infrastructure\OIDC\Issuer_Config;

/**
 * Manages the MyYoast OAuth client registration, tokens, and authorization.
 *
 * These commands are intended to be used with the global --user flag to set the
 * WordPress user context. For example: wp yoast auth status --user=admin
 */
final class Auth_Command implements Command_Interface, Loadable_Interface {

	/**
	 * The MyYoast client facade.
	 *
	 * @var MyYoast_Client
	 */
	private $myyoast_client;

	/**
	 * The client registration port.
	 *
	 * @var Client_Registration_Interface
	 */
	private $client_registration;

	/**
	 * The issuer configuration.
	 *
	 * @var Issuer_Config
	 */
	private $issuer_config;

	/**
	 * The site-level token storage port.
	 *
	 * @var Token_Storage_Interface
	 */
	private $token_storage;

	/**
	 * The user-level token storage port.
	 *
	 * @var User_Token_Storage_Interface
	 */
	private $user_token_storage;

	/**
	 * The cleanup service.
	 *
	 * @var MyYoast_Client_Cleanup
	 */
	private $cleanup;

	/**
	 * Auth_Command constructor.
	 *
	 * @param MyYoast_Client                $myyoast_client      The MyYoast client facade.
	 * @param Client_Registration_Interface $client_registration The client registration port.
	 * @param Issuer_Config                 $issuer_config       The issuer configuration.
	 * @param Token_Storage_Interface       $token_storage       The site-level token storage port.
	 * @param User_Token_Storage_Interface  $user_token_storage  The user-level token storage port.
	 * @param MyYoast_Client_Cleanup        $cleanup             The cleanup service.
	 */
	public function __construct(
		MyYoast_Client $myyoast_client,
		Client_Registration_Interface $client_registration,
		Issuer_Config $issuer_config,
		Token_Storage_Interface $token_storage,
		User_Token_Storage_Interface $user_token_storage,
		MyYoast_Client_Cleanup $cleanup
	) {
		$this->myyoast_client      = $myyoast_client;
		$this->client_registration = $client_registration;
		$this->issuer_config       = $issuer_config;
		$this->token_storage       = $token_storage;
		$this->user_token_storage  = $user_token_storage;
		$this->cleanup             = $cleanup;
	}

	/**
	 * Returns the namespace of this command.
	 *
	 * @return string
	 */
	public static function get_namespace() {
		return Main::WP_CLI_NAMESPACE . ' auth';
	}

	/**
	 * Returns the conditionals based on which this command should be registered.
	 *
	 * @return array<string> The array of conditionals.
	 */
	public static function get_conditionals() {
		return [ MyYoast_Connection_Conditional::class ];
	}

	/**
	 * Shows the current MyYoast OAuth client status.
	 *
	 * Displays issuer configuration, registration state, and token status
	 * without making any network calls. Use the global --user flag to check
	 * a specific user's token status.
	 *
	 * ## OPTIONS
	 *
	 * [--format=<format>]
	 * : Output format.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - json
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     wp yoast auth status
	 *     wp yoast auth status --user=admin
	 *     wp yoast auth status --format=json
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 */
	public function status( $args = null, $assoc_args = null ): void {
		$user_id = \get_current_user_id();

		$issuer_url        = $this->issuer_config->get_issuer_url();
		$has_software      = ( $this->issuer_config->get_software_statement() !== '' );
		$has_iat           = ( $this->issuer_config->get_initial_access_token() !== '' );
		$is_registered     = $this->myyoast_client->is_registered();
		$client_id         = null;
		$registered_client = $this->client_registration->get_registered_client();

		if ( $registered_client !== null ) {
			$client_id = $registered_client->get_client_id();
		}

		$site_token      = $this->token_storage->get();
		$site_token_info = $this->build_token_info( $site_token );

		$user_token      = ( $user_id > 0 ) ? $this->user_token_storage->get( $user_id ) : null;
		$user_token_info = $this->build_token_info( $user_token );

		$data = [
			'issuer_url'           => $issuer_url,
			'software_statement'   => ( $has_software ) ? 'configured' : 'not configured',
			'initial_access_token' => ( $has_iat ) ? 'configured' : 'not configured',
			'registered'           => ( $is_registered ) ? 'yes' : 'no',
			'client_id'            => ( $client_id ?? '-' ),
			'site_token'           => $site_token_info['status'],
			'site_token_expires'   => $site_token_info['expires'],
			'site_token_scopes'    => $site_token_info['scopes'],
			'user_id'              => ( $user_id > 0 ) ? $user_id : 'none (use --user flag)',
			'user_token'           => $user_token_info['status'],
			'user_token_expires'   => $user_token_info['expires'],
			'user_token_scopes'    => $user_token_info['scopes'],
			'user_token_errors'    => $user_token_info['error_count'],
		];

		$this->output( $data, $assoc_args['format'] );
	}

	/**
	 * Registers the site as an OAuth client.
	 *
	 * Performs Dynamic Client Registration (RFC 7591) if the site is not
	 * already registered. Use --force to deregister and re-register.
	 *
	 * ## OPTIONS
	 *
	 * [--force]
	 * : Deregister first, then re-register.
	 *
	 * [--format=<format>]
	 * : Output format.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - json
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     wp yoast auth register
	 *     wp yoast auth register --force
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 *
	 * @throws ExitException When registration fails.
	 */
	public function register( $args = null, $assoc_args = null ): void {
		if ( isset( $assoc_args['force'] ) ) {
			$this->myyoast_client->deregister();
			WP_CLI::log( 'Deregistered existing client.' );
		}

		try {
			$redirect_uri = \get_admin_url( null, 'admin.php?page=' . General_Page_Integration::PAGE . '&yoast_myyoast_oauth_callback=1' );
			$client       = $this->myyoast_client->ensure_registered( [ $redirect_uri ] );
		} catch ( Exception $e ) {
			WP_CLI::error( 'Registration failed: ' . $e->getMessage() );
			return;
		}

		$this->output(
			[
				'client_id' => $client->get_client_id(),
				'status'    => 'registered',
			],
			$assoc_args['format'],
		);

		WP_CLI::success( 'Client registered: ' . $client->get_client_id() );
	}

	/**
	 * Verifies the client registration with the server.
	 *
	 * Reads the current registration from the authorization server to
	 * confirm it is still valid and shows the registration metadata.
	 *
	 * ## OPTIONS
	 *
	 * [--format=<format>]
	 * : Output format.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - json
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     wp yoast auth verify
	 *     wp yoast auth verify --format=json
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 *
	 * @throws ExitException When verification fails.
	 */
	public function verify( $args = null, $assoc_args = null ): void {
		if ( ! $this->myyoast_client->is_registered() ) {
			WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' );
		}

		try {
			$metadata = $this->myyoast_client->verify_registration();
		} catch ( Exception $e ) {
			WP_CLI::error( 'Verification failed: ' . $e->getMessage() );
			return;
		}

		// Redact sensitive fields.
		unset( $metadata['registration_access_token'] );

		$this->output( $this->flatten_for_display( $metadata ), $assoc_args['format'] );

		WP_CLI::success( 'Registration is valid.' );
	}

	/**
	 * Removes the OAuth client registration.
	 *
	 * Deletes the client registration from the authorization server and
	 * clears all local registration data and cached tokens.
	 *
	 * ## OPTIONS
	 *
	 * [--local-only]
	 * : Only delete local data without contacting the server.
	 *
	 * [--yes]
	 * : Skip confirmation prompt.
	 *
	 * ## EXAMPLES
	 *
	 *     wp yoast auth deregister
	 *     wp yoast auth deregister --local-only
	 *     wp yoast auth deregister --yes
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 */
	public function deregister( $args = null, $assoc_args = null ): void {
		if ( ! $this->myyoast_client->is_registered() ) {
			WP_CLI::warning( 'Not registered. Nothing to do.' );
			return;
		}

		WP_CLI::confirm( 'This will deregister this site from MyYoast and clear all cached tokens. Proceed?', $assoc_args );

		if ( isset( $assoc_args['local-only'] ) ) {
			$this->client_registration->delete_local_data();
			$this->myyoast_client->clear_site_token();
			WP_CLI::success( 'Local registration data cleared.' );
			return;
		}

		$result = $this->myyoast_client->deregister();
		$this->myyoast_client->clear_site_token();

		if ( $result ) {
			WP_CLI::success( 'Client deregistered.' );
		}
		else {
			WP_CLI::warning( 'Server-side deregistration failed (network error). Local token was cleared but client credentials remain.' );
		}
	}

	/**
	 * Resets all MyYoast OAuth client state on this site.
	 *
	 * Performs the same cleanup as plugin uninstall: best-effort server-side
	 * deregistration, then deletes all site/user tokens, registered client
	 * credentials, key pairs, and OIDC/JWKS/DPoP caches. Intended for
	 * development environments that need to start from a clean slate without
	 * uninstalling the plugin.
	 *
	 * ## OPTIONS
	 *
	 * [--yes]
	 * : Skip confirmation prompt.
	 *
	 * ## EXAMPLES
	 *
	 *     wp yoast auth reset
	 *     wp yoast auth reset --yes
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 */
	public function reset( $args = null, $assoc_args = null ): void {
		WP_CLI::confirm( 'This will wipe all MyYoast OAuth client state on this site (registered client, site/user tokens, key pairs, OIDC/JWKS/DPoP caches). Proceed?', $assoc_args );

		$this->cleanup->execute();

		WP_CLI::success( 'MyYoast OAuth client state cleared.' );
	}

	/**
	 * Authorizes with MyYoast using the authorization code flow or client credentials.
	 *
	 * Without --site, starts the user authorization code flow:
	 * 1. Run without --code to get the authorization URL.
	 * 2. Visit the URL in a browser and authorize.
	 * 3. Copy the code and state from the callback URL.
	 * 4. Run again with --code and --state to exchange for tokens.
	 *
	 * With --site, performs a client_credentials grant for a site-level token.
	 *
	 * ## OPTIONS
	 *
	 * [--site]
	 * : Use client_credentials grant for a site-level token (no browser needed).
	 *
	 * [--scopes=<scopes>]
	 * : Comma-separated scopes to request.
	 *
	 * [--code=<code>]
	 * : Authorization code from the callback URL (user flow phase 2).
	 *
	 * [--state=<state>]
	 * : State parameter from the callback URL (user flow phase 2).
	 *
	 * [--url-only]
	 * : Only print the authorization URL without instructions (user flow phase 1).
	 *
	 * [--format=<format>]
	 * : Output format.
	 * ---
	 * default: table
	 * options:
	 *   - table
	 *   - json
	 * ---
	 *
	 * ## EXAMPLES
	 *
	 *     # Site-level token (client_credentials):
	 *     wp yoast auth authorize --site --scopes=service:analytics
	 *
	 *     # User authorization code flow, phase 1 - get the URL:
	 *     wp yoast auth authorize --user=admin --scopes=openid,profile
	 *
	 *     # User authorization code flow, phase 2 - exchange the code:
	 *     wp yoast auth authorize --user=admin --code=abc123 --state=xyz789
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 *
	 * @throws ExitException When authorization fails.
	 */
	public function authorize( $args = null, $assoc_args = null ): void {
		$scopes = $this->parse_scopes( $assoc_args );

		if ( isset( $assoc_args['site'] ) ) {
			$this->authorize_site( $scopes, $assoc_args['format'] );
			return;
		}

		$this->authorize_user( $assoc_args, $scopes, $assoc_args['format'] );
	}

	/**
	 * Revokes tokens for the current user and/or the site.
	 *
	 * Without --site, revokes the current user's tokens (requires --user flag).
	 * With --site, clears the cached site-level token.
	 * Both can be combined.
	 *
	 * ## OPTIONS
	 *
	 * [--site]
	 * : Clear the cached site-level token.
	 *
	 * [--yes]
	 * : Skip confirmation prompt.
	 *
	 * ## EXAMPLES
	 *
	 *     wp yoast auth revoke --user=admin
	 *     wp yoast auth revoke --site
	 *     wp yoast auth revoke --user=admin --site --yes
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 */
	public function revoke( $args = null, $assoc_args = null ): void {
		$user_id  = \get_current_user_id();
		$has_site = isset( $assoc_args['site'] );
		$has_user = ( $user_id > 0 );

		if ( ! $has_site && ! $has_user ) {
			WP_CLI::error( 'Specify --site and/or use the global --user flag.' );
		}

		WP_CLI::confirm( 'This will revoke the specified tokens. Proceed?', $assoc_args );

		if ( $has_user ) {
			$this->myyoast_client->revoke_user_token( $user_id );
			WP_CLI::log( \sprintf( 'User %d tokens revoked.', $user_id ) );
		}

		if ( $has_site ) {
			$this->myyoast_client->clear_site_token();
			WP_CLI::log( 'Site token cleared.' );
		}

		WP_CLI::success( 'Done.' );
	}

	/**
	 * Rotates cryptographic key pairs.
	 *
	 * Rotates the registration key pair (server roundtrip) and/or the DPoP
	 * key pair (local only).
	 *
	 * ## OPTIONS
	 *
	 * [--registration]
	 * : Rotate the registration (private_key_jwt) key pair. Requires a server roundtrip.
	 *
	 * [--dpop]
	 * : Rotate the DPoP proof key pair (local only).
	 *
	 * [--all]
	 * : Rotate all key pairs.
	 *
	 * [--yes]
	 * : Skip confirmation prompt.
	 *
	 * ## EXAMPLES
	 *
	 *     wp yoast auth rotate-keys --registration
	 *     wp yoast auth rotate-keys --dpop
	 *     wp yoast auth rotate-keys --all
	 *
	 * @when after_wp_load
	 *
	 * @param array<int, string>|null    $args       The arguments.
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return void
	 *
	 * @throws ExitException When key rotation fails.
	 */
	public function rotate_keys( $args = null, $assoc_args = null ): void {
		$rotate_registration = isset( $assoc_args['registration'] ) || isset( $assoc_args['all'] );
		$rotate_dpop         = isset( $assoc_args['dpop'] ) || isset( $assoc_args['all'] );

		if ( ! $rotate_registration && ! $rotate_dpop ) {
			WP_CLI::error( 'Specify --registration, --dpop, or --all.' );
		}

		WP_CLI::confirm( 'This will rotate the specified key pairs. Existing tokens may be invalidated. Proceed?', $assoc_args );

		if ( $rotate_registration ) {
			if ( ! $this->myyoast_client->is_registered() ) {
				WP_CLI::error( 'Not registered. Run "wp yoast auth register" first.' );
			}

			try {
				$client = $this->myyoast_client->rotate_registration_keys();
				WP_CLI::log( 'Registration keys rotated. Client ID: ' . $client->get_client_id() );
			} catch ( Exception $e ) {
				WP_CLI::error( 'Registration key rotation failed: ' . $e->getMessage() );
				return;
			}
		}

		if ( $rotate_dpop ) {
			$this->myyoast_client->rotate_dpop_keys();
			WP_CLI::log( 'DPoP keys rotated.' );
		}

		WP_CLI::success( 'Key rotation complete.' );
	}

	/**
	 * Performs a client_credentials grant for a site-level token.
	 *
	 * @param string[] $scopes The scopes to request.
	 * @param string   $format The output format.
	 *
	 * @return void
	 *
	 * @throws ExitException When the token request fails.
	 */
	private function authorize_site( array $scopes, string $format ): void {
		try {
			$token_set = $this->myyoast_client->get_site_token( $scopes );
		} catch ( Exception $e ) {
			WP_CLI::error( 'Site token request failed: ' . $e->getMessage() );
			return;
		}

		$this->output( $this->build_token_info( $token_set ), $format );

		WP_CLI::success( 'Site token obtained.' );
	}

	/**
	 * Handles the user authorization code flow.
	 *
	 * @param array<string, string> $assoc_args The associative arguments.
	 * @param string[]              $scopes     The scopes to request.
	 * @param string                $format     The output format.
	 *
	 * @return void
	 *
	 * @throws ExitException When authorization fails.
	 */
	private function authorize_user( array $assoc_args, array $scopes, string $format ): void {
		$user_id = \get_current_user_id();
		if ( $user_id <= 0 ) {
			WP_CLI::error( 'User authorization requires the global --user flag.' );
		}

		$has_code  = isset( $assoc_args['code'] );
		$has_state = isset( $assoc_args['state'] );

		// Phase 2: exchange the code.
		if ( $has_code && $has_state ) {
			try {
				$token_set = $this->myyoast_client->exchange_authorization_code(
					$user_id,
					$assoc_args['code'],
					$assoc_args['state'],
				);
			} catch ( Exception $e ) {
				WP_CLI::error( 'Code exchange failed: ' . $e->getMessage() );
				return;
			}

			$this->output( $this->build_token_info( $token_set ), $format );

			WP_CLI::success( 'User authorized.' );
			return;
		}

		if ( $has_code || $has_state ) {
			WP_CLI::error( 'Both --code and --state are required for code exchange.' );
		}

		// Phase 1: generate the authorization URL.
		$redirect_uri = \get_admin_url( null, 'admin.php?page=' . General_Page_Integration::PAGE . '&yoast_myyoast_oauth_callback=1' );

		try {
			$url = $this->myyoast_client->get_authorization_url( $user_id, $redirect_uri, $scopes );
		} catch ( Exception $e ) {
			WP_CLI::error( 'Failed to generate authorization URL: ' . $e->getMessage() );
			return;
		}

		if ( isset( $assoc_args['url-only'] ) ) {
			WP_CLI::log( $url );
			return;
		}

		WP_CLI::log( 'Visit this URL to authorize:' );
		WP_CLI::log( '' );
		WP_CLI::log( $url );
		WP_CLI::log( '' );
		WP_CLI::log( 'After authorizing, you will be redirected to a callback URL.' );
		WP_CLI::log( 'If you need to manually complete authorization, copy the "code" and "state" parameters from the URL, then run:' );
		WP_CLI::log( '' );
		WP_CLI::log(
			\sprintf(
				'  wp yoast auth authorize --user=%s --code=<CODE> --state=<STATE>',
				$user_id,
			),
		);
	}

	/**
	 * Builds a display-safe token info array.
	 *
	 * @param Token_Set|null $token_set The token set, or null.
	 *
	 * @return array<string, string|int> The token info for display.
	 */
	private function build_token_info( ?Token_Set $token_set ): array {
		if ( $token_set === null ) {
			return [
				'status'      => 'none',
				'expires'     => '-',
				'scopes'      => '-',
				'error_count' => '-',
			];
		}

		return [
			'status'      => ( $token_set->is_expired() ) ? 'expired' : 'valid',
			'expires'     => \gmdate( 'Y-m-d H:i:s', $token_set->get_expires_at() ) . ' UTC',
			'scopes'      => ( $token_set->get_scope() ?? '-' ),
			'error_count' => $token_set->get_error_count(),
		];
	}

	/**
	 * Parses the comma-separated scopes option.
	 *
	 * @param array<string, string>|null $assoc_args The associative arguments.
	 *
	 * @return string[] The parsed scopes.
	 */
	private function parse_scopes( $assoc_args ): array {
		if ( ! isset( $assoc_args['scopes'] ) || $assoc_args['scopes'] === '' ) {
			return [];
		}

		return \array_values( \array_filter( \array_map( 'trim', \explode( ',', $assoc_args['scopes'] ) ) ) );
	}

	/**
	 * Flattens nested arrays for table display by JSON-encoding array values.
	 *
	 * @param array<string, string|string[]|bool> $data The data to flatten.
	 *
	 * @return array<string, string> The flattened data.
	 */
	private function flatten_for_display( array $data ): array {
		$result = [];
		foreach ( $data as $key => $value ) {
			if ( \is_array( $value ) ) {
				// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.Found -- WP-CLI display output, not user-facing HTML.
				$result[ $key ] = ( \wp_json_encode( $value ) ?? '[]' );
			}
			elseif ( \is_bool( $value ) ) {
				$result[ $key ] = ( $value ) ? 'true' : 'false';
			}
			else {
				$result[ $key ] = (string) $value;
			}
		}
		return $result;
	}

	/**
	 * Outputs data in the requested format.
	 *
	 * @param array<string, string|int> $data   The data to output.
	 * @param string                    $format The output format (table or json).
	 *
	 * @return void
	 */
	private function output( array $data, string $format ): void {
		if ( $format === 'json' ) {
			// phpcs:ignore Yoast.Yoast.JsonEncodeAlternative.FoundWithAdditionalParams -- CLI output, not user-facing HTML.
			$encoded = \wp_json_encode( $data, \JSON_PRETTY_PRINT );
			WP_CLI::log( ( $encoded !== false ) ? $encoded : '{}' );
			return;
		}

		$items = [];
		foreach ( $data as $key => $value ) {
			$items[] = [
				'field' => $key,
				'value' => (string) $value,
			];
		}

		WP_CLI\Utils\format_items( 'table', $items, [ 'field', 'value' ] );
	}
}
