<?php
/**
 * Apple Sign In OAuth Provider for Guard Dog Social Login.
 *
 * Implements the social provider interface for Apple ID authentication.
 * Uses Apple's OAuth 2.0 / OpenID Connect flow with JWT-based client secrets.
 *
 * Apple-specific quirks handled by this provider:
 * - Client secret is a JWT signed with a .p8 private key (ES256)
 * - User data (name, email) is only sent on first authorization via POST
 * - Uses response_mode=form_post (callback via POST, not GET)
 * - No avatar/profile picture available from Apple
 * - No resource owner details endpoint; user data comes from the id_token JWT
 *
 * @package GuardDog
 * @since 1.9.30
 */

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

use GuardDog\Vendor\Firebase\JWT\JWT;
use GuardDog\Vendor\Firebase\JWT\JWK;
use GuardDog\Vendor\Firebase\JWT\Key;
use GuardDog\Vendor\League\OAuth2\Client\Provider\AbstractProvider;
use GuardDog\Vendor\League\OAuth2\Client\Provider\ResourceOwnerInterface;
use GuardDog\Vendor\League\OAuth2\Client\Provider\GenericResourceOwner;
use GuardDog\Vendor\League\OAuth2\Client\Token\AccessToken;
use GuardDog\Vendor\League\OAuth2\Client\Tool\BearerAuthorizationTrait;
use GuardDog\Vendor\Psr\Http\Message\ResponseInterface;

/**
 * Class Guard_Dog_Apple_League_Provider
 *
 * Custom League OAuth2 provider for Apple Sign In.
 * Extends AbstractProvider to handle Apple's unique OAuth implementation:
 * - Adds response_mode=form_post to authorization requests
 * - Parses user data from the id_token JWT instead of a resource owner endpoint
 *
 * @since 1.9.30
 */
class Guard_Dog_Apple_League_Provider extends AbstractProvider {

	use BearerAuthorizationTrait;

	/**
	 * Get the authorization URL.
	 *
	 * @return string
	 */
	public function getBaseAuthorizationUrl(): string {
		return 'https://appleid.apple.com/auth/authorize';
	}

	/**
	 * Get the access token URL.
	 *
	 * @param array $params Token request parameters.
	 * @return string
	 */
	public function getBaseAccessTokenUrl( array $params ): string {
		return 'https://appleid.apple.com/auth/token';
	}

	/**
	 * Apple does not have a resource owner details endpoint.
	 * User data comes from the id_token JWT in the token response.
	 *
	 * @param AccessToken $token Access token.
	 * @return string Empty string (not used).
	 */
	public function getResourceOwnerDetailsUrl( AccessToken $token ): string {
		return '';
	}

	/**
	 * Get the default scopes for Apple Sign In.
	 *
	 * @return array
	 */
	protected function getDefaultScopes(): array {
		return array( 'name', 'email' );
	}

	/**
	 * Get the scope separator.
	 *
	 * @return string
	 */
	protected function getScopeSeparator(): string {
		return ' ';
	}

	/**
	 * Add Apple-specific authorization parameters.
	 *
	 * Apple requires response_mode=form_post when requesting name or email scopes.
	 * This causes the callback to be sent as a POST request.
	 *
	 * @param array $options Authorization options.
	 * @return array Modified options with response_mode.
	 */
	protected function getAuthorizationParameters( array $options ): array {
		$params = parent::getAuthorizationParameters( $options );
		$params['response_mode'] = 'form_post';
		return $params;
	}

	/**
	 * Check the token response for errors.
	 *
	 * @param ResponseInterface $response HTTP response.
	 * @param array|string      $data     Parsed response data.
	 * @throws \GuardDog\Vendor\League\OAuth2\Client\Provider\Exception\IdentityProviderException On error.
	 */
	protected function checkResponse( ResponseInterface $response, $data ): void {
		if ( $response->getStatusCode() >= 400 || ! empty( $data['error'] ) ) {
			$error   = $data['error'] ?? 'unknown_error';
			$message = $data['error_description'] ?? $error;

			throw new \GuardDog\Vendor\League\OAuth2\Client\Provider\Exception\IdentityProviderException(
				$message,
				$response->getStatusCode(),
				$data
			);
		}
	}

	/**
	 * Create a resource owner from the id_token JWT claims.
	 *
	 * Instead of fetching from a URL, Apple's user data comes from
	 * the id_token included in the access token response.
	 *
	 * @param array       $response Token response data.
	 * @param AccessToken $token    Access token.
	 * @return ResourceOwnerInterface
	 */
	protected function createResourceOwner( array $response, AccessToken $token ): ResourceOwnerInterface {
		$id_token = $token->getValues()['id_token'] ?? '';
		$claims   = array();

		if ( ! empty( $id_token ) ) {
			$claims = $this->verify_and_decode_id_token( $id_token );
		}

		return new GenericResourceOwner( $claims, 'sub' );
	}

	/**
	 * Verify and decode Apple's id_token JWT using Apple's public JWKS.
	 *
	 * Fetches Apple's public keys from https://appleid.apple.com/auth/keys,
	 * verifies the JWT signature cryptographically, and validates claims.
	 * Keys are cached for 1 hour via WordPress transients.
	 *
	 * @param string $id_token The raw JWT id_token from Apple.
	 * @return array Verified and decoded claims, or empty array on failure.
	 */
	private function verify_and_decode_id_token( string $id_token ): array {
		$jwks = $this->get_apple_jwks();
		if ( empty( $jwks ) ) {
			if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug ) {
				Guard_Dog()->debug->error(
					'Apple JWKS unavailable while verifying id_token.',
					'social_login'
				);
			}
			return array();
		}

		try {
			$keys    = JWK::parseKeySet( $jwks );
			$decoded = JWT::decode( $id_token, $keys );
			$claims  = (array) $decoded;

			// Verify issuer
			if ( ( $claims['iss'] ?? '' ) !== 'https://appleid.apple.com' ) {
				if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug ) {
					Guard_Dog()->debug->error(
						'Apple id_token issuer mismatch.',
						'social_login',
						array(
							'issuer' => $claims['iss'] ?? '',
						)
					);
				}
				return array();
			}

			// Verify audience matches our client ID
			$client_id = $this->clientId ?? '';
			$audience  = $claims['aud'] ?? '';
			$audiences = is_array( $audience ) ? array_map( 'strval', $audience ) : array( (string) $audience );

			if ( ! empty( $client_id ) && ! in_array( $client_id, $audiences, true ) ) {
				if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug ) {
					Guard_Dog()->debug->error(
						'Apple id_token audience mismatch.',
						'social_login',
						array(
							'expected_audience' => $client_id,
							'received_audience' => $audiences,
						)
					);
				}
				return array();
			}

			return $claims;
		} catch ( \Exception $e ) {
			if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled() ) {
				Guard_Dog()->debug->error( 'Apple id_token verification failed: ' . $e->getMessage() );
			}
			return array();
		}
	}

	/**
	 * Fetch Apple's public JWKS for id_token verification.
	 *
	 * Results are cached for 1 hour. If fetching fails, returns empty array.
	 *
	 * @return array JWKS data or empty array on failure.
	 */
	private function get_apple_jwks(): array {
		$cache_key = 'guard_dog_apple_jwks';
		$cached    = get_transient( $cache_key );

		if ( is_array( $cached ) && ! empty( $cached ) ) {
			return $cached;
		}

		$response = wp_remote_get( 'https://appleid.apple.com/auth/keys', array(
			'timeout' => 10,
		) );

		if ( is_wp_error( $response ) ) {
			if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug ) {
				Guard_Dog()->debug->error(
					'Failed to fetch Apple JWKS.',
					'social_login',
					array(
						'error' => $response->get_error_message(),
					)
				);
			}
			return array();
		}

		$status_code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $status_code ) {
			if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug ) {
				Guard_Dog()->debug->error(
					'Apple JWKS request returned a non-200 response.',
					'social_login',
					array(
						'status_code' => $status_code,
					)
				);
			}
			return array();
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $body ) || empty( $body['keys'] ) ) {
			if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug ) {
				Guard_Dog()->debug->error(
					'Apple JWKS response was empty or malformed.',
					'social_login'
				);
			}
			return array();
		}

		set_transient( $cache_key, $body, HOUR_IN_SECONDS );

		return $body;
	}

	/**
	 * Override getResourceOwner to use createResourceOwner with token values.
	 *
	 * Apple has no resource owner endpoint, so we skip the HTTP request
	 * and build the resource owner from the id_token JWT claims.
	 *
	 * @param AccessToken $token Access token containing the id_token.
	 * @return ResourceOwnerInterface
	 */
	public function getResourceOwner( AccessToken $token ): ResourceOwnerInterface {
		return $this->createResourceOwner( $token->getValues(), $token );
	}
}

/**
 * Class Guard_Dog_Apple_Provider
 *
 * Apple Sign In social login provider implementation.
 * Uses a custom League OAuth2 provider with Apple-specific endpoints,
 * JWT client secret generation, and id_token-based user data extraction.
 *
 * @since 1.9.30
 */
class Guard_Dog_Apple_Provider implements Guard_Dog_Social_Provider_Interface {

	/**
	 * Prefix added to encrypted values to identify them.
	 */
	private const ENCRYPTED_PREFIX = '{gd:enc}';

	/**
	 * Check whether a stored option value uses Guard Dog's encrypted format.
	 *
	 * @param string $stored Stored key value.
	 * @return bool
	 */
	public static function is_encrypted_key( string $stored ): bool {
		return strpos( $stored, self::ENCRYPTED_PREFIX ) === 0;
	}

	/**
	 * Normalize pasted Apple key content into PEM text.
	 *
	 * Handles common copy/paste mistakes such as wrapping quotes,
	 * CRLF line endings, and literal "\n" sequences copied from
	 * environment variables or JSON blobs.
	 *
	 * @param string $key_file Raw key content.
	 * @return string
	 */
	public static function normalize_key_file( string $key_file ): string {
		$key_file = trim( $key_file );

		if ( strlen( $key_file ) >= 2 ) {
			$first = $key_file[0];
			$last  = $key_file[ strlen( $key_file ) - 1 ];

			if ( ( '"' === $first && '"' === $last ) || ( "'" === $first && "'" === $last ) ) {
				$key_file = substr( $key_file, 1, -1 );
			}
		}

		if ( false === strpos( $key_file, "\n" ) && false !== strpos( $key_file, '\n' ) ) {
			$key_file = str_replace( [ '\r\n', '\n', '\r' ], [ "\n", "\n", "\r" ], $key_file );
		}

		$key_file = str_replace( [ "\r\n", "\r" ], "\n", $key_file );

		return trim( $key_file );
	}

	/**
	 * Check whether the provided Apple private key can be parsed by OpenSSL.
	 *
	 * @param string $key_file PEM key contents.
	 * @return bool
	 */
	public static function is_valid_private_key( string $key_file ): bool {
		$key_file = self::normalize_key_file( $key_file );
		if ( empty( $key_file ) ) {
			return false;
		}

		$private_key = openssl_pkey_get_private( $key_file );
		if ( false === $private_key ) {
			return false;
		}

		$details = openssl_pkey_get_details( $private_key );
		if ( false === $details ) {
			return false;
		}

		if ( isset( $details['type'] ) && OPENSSL_KEYTYPE_EC !== $details['type'] ) {
			return false;
		}

		if ( isset( $details['ec']['curve_name'] ) && 'prime256v1' !== $details['ec']['curve_name'] ) {
			return false;
		}

		return true;
	}

	/**
	 * Encrypt a value for storage in wp_options.
	 *
	 * Uses AES-256-CBC with WordPress AUTH_KEY. The encrypted value is
	 * prefixed with a marker so we can detect encrypted vs plaintext.
	 *
	 * @since 1.9.30
	 * @param string $plaintext The value to encrypt.
	 * @return string The encrypted value with prefix, or original on failure.
	 */
	public static function encrypt_key( string $plaintext ): string {
		$plaintext = self::normalize_key_file( $plaintext );

		if ( empty( $plaintext ) ) {
			return '';
		}

		if ( ! defined( 'AUTH_KEY' ) || ! defined( 'AUTH_SALT' ) ) {
			return $plaintext;
		}

		$key = hash( 'sha256', AUTH_KEY, true );
		$iv  = substr( hash( 'sha256', AUTH_SALT, true ), 0, 16 );

		$encrypted = openssl_encrypt( $plaintext, 'aes-256-cbc', $key, 0, $iv );
		if ( $encrypted === false ) {
			return $plaintext;
		}

		return self::ENCRYPTED_PREFIX . $encrypted;
	}

	/**
	 * Decrypt a value from wp_options.
	 *
	 * Detects the encryption prefix and decrypts. Returns plaintext
	 * values unchanged for backwards compatibility with existing installs.
	 *
	 * @since 1.9.30
	 * @param string $stored The stored value (possibly encrypted).
	 * @return string The decrypted plaintext value.
	 */
	public static function decrypt_key( string $stored ): string {
		if ( empty( $stored ) ) {
			return '';
		}

		if ( ! self::is_encrypted_key( $stored ) ) {
			return $stored;
		}

		if ( ! defined( 'AUTH_KEY' ) || ! defined( 'AUTH_SALT' ) ) {
			return '';
		}

		$key       = hash( 'sha256', AUTH_KEY, true );
		$iv        = substr( hash( 'sha256', AUTH_SALT, true ), 0, 16 );
		$encrypted = substr( $stored, strlen( self::ENCRYPTED_PREFIX ) );

		$decrypted = openssl_decrypt( $encrypted, 'aes-256-cbc', $key, 0, $iv );
		if ( $decrypted === false ) {
			return '';
		}

		return $decrypted;
	}

	/**
	 * Get the unique provider identifier.
	 *
	 * @since 1.9.30
	 * @return string Provider identifier.
	 */
	public function get_id(): string {
		return 'apple';
	}

	/**
	 * Get the human-readable provider name.
	 *
	 * @since 1.9.30
	 * @return string Translated provider display name.
	 */
	public function get_name(): string {
		return __( 'Apple', 'guard-dog' );
	}

	/**
	 * Get the Apple brand icon as an inline SVG string.
	 *
	 * Uses the official Apple logo.
	 *
	 * @since 1.9.30
	 * @return string SVG markup for the Apple brand icon.
	 */
	public function get_icon_svg(): string {
		return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="currentColor">'
			. '<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>'
			. '</svg>';
	}

	/**
	 * Get the Apple primary brand color.
	 *
	 * @since 1.9.30
	 * @return string CSS hex color value.
	 */
	public function get_color(): string {
		return '#000000';
	}

	/**
	 * Get the OAuth scopes required for Apple authentication.
	 *
	 * Requests name and email from Apple ID. These scopes trigger
	 * Apple to use response_mode=form_post for the callback.
	 *
	 * @since 1.9.30
	 * @return array List of Apple OAuth scope strings.
	 */
	public function get_scopes(): array {
		return array( 'name', 'email' );
	}

	/**
	 * Generate a JWT client secret for Apple OAuth.
	 *
	 * Apple requires a short-lived JWT signed with the app's private key
	 * instead of a static client secret. The JWT contains:
	 * - iss: Team ID
	 * - aud: https://appleid.apple.com
	 * - sub: Client (Services) ID
	 * - iat: Current time
	 * - exp: Current time + 1 hour (max 6 months)
	 *
	 * @since 1.9.30
	 *
	 * @param string $client_id Apple Services ID.
	 * @param string $team_id   Apple Developer Team ID.
	 * @param string $key_id    Private key identifier.
	 * @param string $key_file  Contents of the .p8 private key file.
	 * @return string Signed JWT client secret.
	 * @throws \Exception If JWT generation fails.
	 */
	public function generate_client_secret( string $client_id, string $team_id, string $key_id, string $key_file ): string {
		$key_file = self::normalize_key_file( $key_file );

		if ( empty( $key_file ) ) {
			throw new \RuntimeException(
				__( 'Apple private key is missing. Paste the full .p8 key into Guard Dog settings and save again.', 'guard-dog' )
			);
		}

		if ( ! self::is_valid_private_key( $key_file ) ) {
			throw new \RuntimeException(
				__( 'Apple private key could not be parsed by OpenSSL. Re-save the raw .p8 key in Guard Dog settings.', 'guard-dog' )
			);
		}

		$now = time();

		$payload = array(
			'iss' => $team_id,
			'aud' => 'https://appleid.apple.com',
			'sub' => $client_id,
			'iat' => $now,
			'exp' => $now + 3600,
		);

		return JWT::encode( $payload, $key_file, 'ES256', $key_id );
	}

	/**
	 * Create the League OAuth2 Apple provider instance.
	 *
	 * Builds a custom Apple League provider with JWT client secret.
	 * The provider handles Apple's response_mode=form_post and
	 * id_token-based resource owner extraction automatically.
	 *
	 * @since 1.9.30
	 *
	 * @param array $config {
	 *     Provider configuration.
	 *
	 *     @type string $client_id     Apple Services ID.
	 *     @type string $client_secret Unused for Apple (JWT generated internally).
	 *     @type string $redirect_uri  OAuth callback URL.
	 * }
	 * @return AbstractProvider Configured Apple provider.
	 * @throws \Exception If JWT client secret generation fails.
	 */
	public function create_league_provider( array $config ): AbstractProvider {
		$client_id = $config['client_id'] ?? '';
		$team_id   = get_option( 'guard_dog_social_apple_team_id', '' );
		$key_id    = get_option( 'guard_dog_social_apple_key_id', '' );
		$stored_key = (string) get_option( 'guard_dog_social_apple_key_file', '' );
		$key_file   = self::decrypt_key( $stored_key );

		if ( empty( $key_file ) ) {
			if ( self::is_encrypted_key( $stored_key ) ) {
				throw new \RuntimeException(
					__( 'Apple private key could not be decrypted on this server. Re-save the .p8 key after migrating hosts or changing WordPress salts.', 'guard-dog' )
				);
			}

			throw new \RuntimeException(
				__( 'Apple private key is missing. Paste the full .p8 key into Guard Dog settings and save again.', 'guard-dog' )
			);
		}

		$client_secret = $this->generate_client_secret( $client_id, $team_id, $key_id, $key_file );

		return new Guard_Dog_Apple_League_Provider(
			array(
				'clientId'     => $client_id,
				'clientSecret' => $client_secret,
				'redirectUri'  => $config['redirect_uri'] ?? '',
			)
		);
	}

	/**
	 * Normalize an Apple resource owner into a standard user data array.
	 *
	 * Apple's resource owner data is limited - the id_token JWT contains
	 * the user's subject ID and email. Name data is only available on
	 * first authorization via POST body (handled separately in the callback).
	 *
	 * @since 1.9.30
	 *
	 * @param ResourceOwnerInterface $resource_owner The Apple user resource owner.
	 * @return array Normalized user data array.
	 */
	public function normalize_resource_owner( ResourceOwnerInterface $resource_owner ): array {
		$data = $resource_owner->toArray();

		return array(
			'provider_user_id' => (string) ( $data['sub'] ?? $resource_owner->getId() ?? '' ),
			'email'            => $data['email'] ?? '',
			'email_verified'   => $data['email_verified'] ?? false,
			'name'             => '',
			'first_name'       => null,
			'last_name'        => null,
			'avatar_url'       => null,
		);
	}

	/**
	 * Check if this provider has all required configuration.
	 *
	 * Apple requires a Services ID, Team ID, Key ID, and .p8 private key.
	 *
	 * @since 1.9.30
	 * @return bool True if all required options are configured.
	 */
	public function is_configured(): bool {
		$client_id = get_option( 'guard_dog_social_apple_client_id', '' );
		$team_id   = get_option( 'guard_dog_social_apple_team_id', '' );
		$key_id    = get_option( 'guard_dog_social_apple_key_id', '' );
		$key_file  = self::decrypt_key( get_option( 'guard_dog_social_apple_key_file', '' ) );

		return ! empty( $client_id ) && ! empty( $team_id ) && ! empty( $key_id ) && self::is_valid_private_key( $key_file );
	}

	/**
	 * Get the admin settings fields for Apple Sign In configuration.
	 *
	 * Apple requires four configuration values from the Apple Developer portal:
	 * Services ID, Team ID, Key ID, and the .p8 private key file content.
	 *
	 * @since 1.9.30
	 *
	 * @return array List of settings field definitions.
	 */
	public function get_settings_fields(): array {
		return array(
			array(
				'id'          => 'client_id',
				'label'       => __( 'Services ID', 'guard-dog' ),
				'type'        => 'text',
				'required'    => true,
				'description' => __( 'From Apple Developer > Certificates, Identifiers & Profiles > Identifiers > Services IDs.', 'guard-dog' ),
			),
			array(
				'id'          => 'team_id',
				'label'       => __( 'Team ID', 'guard-dog' ),
				'type'        => 'text',
				'required'    => true,
				'description' => __( 'Your 10-character Team ID from Apple Developer > Membership details.', 'guard-dog' ),
			),
			array(
				'id'          => 'key_id',
				'label'       => __( 'Key ID', 'guard-dog' ),
				'type'        => 'text',
				'required'    => true,
				'description' => __( 'From Apple Developer > Certificates, Identifiers & Profiles > Keys.', 'guard-dog' ),
			),
			array(
				'id'          => 'key_file',
				'label'       => __( 'Key File Content', 'guard-dog' ),
				'type'        => 'textarea',
				'required'    => true,
				'description' => __( 'Paste the full contents of the .p8 private key file downloaded from Apple.', 'guard-dog' ),
			),
		);
	}
}
