<?php
/**
 * @package Hafenstudios\Leadlotse
 */

namespace Hafenstudios\Leadlotse\Identity;

defined( 'ABSPATH' ) || exit;

/**
 * Identity-Anbieter Apple ("Sign in with Apple", Authorization Code Flow).
 *
 * Besonderheiten gegenueber Google:
 * - Das Client-Secret ist kein statischer String, sondern ein von uns signiertes,
 *   kurzlebiges ES256-JWT (Team-ID, Key-ID, Services-ID, p8-Signaturschluessel).
 * - Der Name kommt NICHT aus dem Token, sondern nur beim allerersten Login als
 *   separater POST-Parameter 'user' (JSON) mit; danach nie wieder.
 * - Die verifizierte E-Mail steht im id_token aus der Token-Antwort. Es findet
 *   bewusst KEINE lokale Signaturpruefung des id_token statt: das Token kommt ueber
 *   den TLS-Kanal direkt aus der Server-zu-Server-Antwort von Apple, der damit der
 *   Vertrauensanker ist (kein Umweg ueber oeffentlich abgerufene Schluessel).
 *
 * Die HTTP-Schicht liegt in der protected Methode http_post, damit Tests sie per
 * Subklasse ersetzen koennen (die Klasse ist deshalb nicht final).
 */
class AppleProvider implements ProviderInterface {

	private const AUTHORIZE_ENDPOINT = 'https://appleid.apple.com/auth/authorize';
	private const TOKEN_ENDPOINT      = 'https://appleid.apple.com/auth/token';
	private const AUDIENCE            = 'https://appleid.apple.com';

	private string $client_id;

	private string $team_id;

	private string $key_id;

	private string $private_key;

	/**
	 * @param string $client_id   Services-ID (die Apple-"Client-ID", Klartext).
	 * @param string $team_id     Apple-Team-ID.
	 * @param string $key_id      Key-ID des Sign-in-with-Apple-Schluessels.
	 * @param string $private_key Inhalt der .p8-Datei (Klartext).
	 */
	public function __construct( string $client_id, string $team_id, string $key_id, string $private_key ) {
		$this->client_id   = $client_id;
		$this->team_id     = $team_id;
		$this->key_id      = $key_id;
		$this->private_key = $private_key;
	}

	/**
	 * {@inheritDoc}
	 */
	public function key(): string {
		return 'apple';
	}

	/**
	 * {@inheritDoc}
	 */
	public function label(): string {
		return 'Apple';
	}

	/**
	 * {@inheritDoc}
	 */
	public function is_configured(): bool {
		return '' !== $this->client_id && '' !== $this->team_id && '' !== $this->key_id && '' !== $this->private_key;
	}

	/**
	 * {@inheritDoc}
	 */
	public function authorize_url( string $state, string $redirect_uri ): string {
		$query = http_build_query(
			array(
				'client_id'     => $this->client_id,
				'redirect_uri'  => $redirect_uri,
				'response_type' => 'code',
				'scope'         => 'name email',
				'response_mode' => 'form_post',
				'state'         => $state,
			)
		);
		return self::AUTHORIZE_ENDPOINT . '?' . $query;
	}

	/**
	 * Baut das kurzlebige ES256-Client-Secret-JWT.
	 *
	 * @return string Signiertes JWT (leer bei Signaturfehler).
	 */
	public function client_secret(): string {
		$now    = time();
		$header = array(
			'alg' => 'ES256',
			'kid' => $this->key_id,
			'typ' => 'JWT',
		);
		$claims = array(
			'iss' => $this->team_id,
			'iat' => $now,
			'exp' => $now + 3600,
			'aud' => self::AUDIENCE,
			'sub' => $this->client_id,
		);

		$signing_input = self::base64url_encode( (string) wp_json_encode( $header ) )
			. '.' . self::base64url_encode( (string) wp_json_encode( $claims ) );

		$key = openssl_pkey_get_private( $this->private_key );
		if ( false === $key ) {
			return '';
		}

		$der = '';
		if ( ! openssl_sign( $signing_input, $der, $key, OPENSSL_ALGO_SHA256 ) ) {
			return '';
		}

		return $signing_input . '.' . self::base64url_encode( self::der_to_raw( $der ) );
	}

	/**
	 * {@inheritDoc}
	 */
	public function exchange( string $code, string $redirect_uri, array $extra = array() ) {
		$secret = $this->client_secret();
		if ( '' === $secret ) {
			return $this->error();
		}

		$token = $this->http_post(
			self::TOKEN_ENDPOINT,
			array(
				'code'          => $code,
				'client_id'     => $this->client_id,
				'client_secret' => $secret,
				'redirect_uri'  => $redirect_uri,
				'grant_type'    => 'authorization_code',
			)
		);
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		$claims = self::decode_id_token( (string) ( $token['id_token'] ?? '' ) );
		$email  = sanitize_email( (string) ( $claims['email'] ?? '' ) );
		if ( '' === $email ) {
			return new \WP_Error(
				'leadlotse_identity_unverified',
				__( 'Apple hat keine E-Mail-Adresse zurueckgegeben. Bitte fuellen Sie das Formular von Hand aus.', 'leadlotse' )
			);
		}

		// Apple liefert email_verified teils als String 'true'. Fehlt der Wert, gilt
		// die Adresse als verifiziert (Apple bestaetigt sie im eigenen Konto).
		$verified = ! isset( $claims['email_verified'] ) || filter_var( $claims['email_verified'], FILTER_VALIDATE_BOOLEAN );

		return array(
			'email'          => $email,
			'email_verified' => (bool) $verified,
			'name'           => $this->name_from_extra( $extra ),
		);
	}

	/**
	 * Liest den Namen aus dem beim ersten Login mitgeschickten 'user'-Parameter.
	 *
	 * @param array<string, mixed> $extra Callback-Parameter.
	 * @return string
	 */
	private function name_from_extra( array $extra ): string {
		$raw = (string) ( $extra['user'] ?? '' );
		if ( '' === $raw ) {
			return '';
		}
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) || ! isset( $data['name'] ) || ! is_array( $data['name'] ) ) {
			return '';
		}
		$first = trim( (string) ( $data['name']['firstName'] ?? '' ) );
		$last  = trim( (string) ( $data['name']['lastName'] ?? '' ) );
		return sanitize_text_field( trim( $first . ' ' . $last ) );
	}

	/**
	 * Dekodiert das mittlere (Payload-)Segment eines JWT.
	 *
	 * @param string $id_token id_token aus der Token-Antwort.
	 * @return array<string, mixed>
	 */
	private static function decode_id_token( string $id_token ): array {
		$parts = explode( '.', $id_token );
		if ( 3 !== count( $parts ) ) {
			return array();
		}
		$payload = base64_decode( strtr( $parts[1], '-_', '+/' ), true );
		if ( false === $payload ) {
			return array();
		}
		$data = json_decode( $payload, true );
		return is_array( $data ) ? $data : array();
	}

	/**
	 * Wandelt eine DER-kodierte ECDSA-Signatur in das rohe R||S-Format (je 32 Byte).
	 *
	 * @param string $der DER-Signatur (SEQUENCE zweier INTEGER).
	 * @return string 64 Byte (R links mit Nullbytes aufgefuellt, dann S).
	 */
	protected static function der_to_raw( string $der ): string {
		$pos = 0;

		$read_len = static function () use ( &$der, &$pos ) {
			$len = ord( $der[ $pos++ ] );
			if ( $len & 0x80 ) {
				$bytes = $len & 0x7f;
				$len   = 0;
				for ( $i = 0; $i < $bytes; $i++ ) {
					$len = ( $len << 8 ) | ord( $der[ $pos++ ] );
				}
			}
			return $len;
		};

		++$pos;        // SEQUENCE-Tag (0x30).
		$read_len();   // Gesamtlaenge (ignoriert).

		++$pos;                 // INTEGER-Tag (0x02) fuer R.
		$rlen = $read_len();
		$r    = substr( $der, $pos, $rlen );
		$pos += $rlen;

		++$pos;                 // INTEGER-Tag (0x02) fuer S.
		$slen = $read_len();
		$s    = substr( $der, $pos, $slen );

		$r = ltrim( $r, "\x00" );
		$s = ltrim( $s, "\x00" );

		return str_pad( substr( $r, -32 ), 32, "\x00", STR_PAD_LEFT )
			. str_pad( substr( $s, -32 ), 32, "\x00", STR_PAD_LEFT );
	}

	/**
	 * base64url-Kodierung ohne Padding.
	 *
	 * @param string $data Rohdaten.
	 * @return string
	 */
	private static function base64url_encode( string $data ): string {
		return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
	}

	/**
	 * Sendet einen POST-Request und liefert den dekodierten JSON-Body.
	 *
	 * @param string               $url  Ziel-URL.
	 * @param array<string, mixed> $body Formularfelder.
	 * @return array<string, mixed>|\WP_Error
	 */
	protected function http_post( string $url, array $body ) {
		$response = wp_remote_post(
			$url,
			array(
				'timeout' => 15,
				'body'    => $body,
				'headers' => array( 'Accept' => 'application/json' ),
			)
		);
		if ( is_wp_error( $response ) ) {
			return $this->error();
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
		if ( $code < 200 || $code >= 300 || ! is_array( $data ) ) {
			return $this->error();
		}
		return $data;
	}

	/**
	 * Einheitlicher, verstaendlicher Verbindungsfehler.
	 *
	 * @return \WP_Error
	 */
	private function error(): \WP_Error {
		return new \WP_Error(
			'leadlotse_identity_http',
			__( 'Die Verbindung zu Apple ist fehlgeschlagen. Bitte fuellen Sie das Formular von Hand aus.', 'leadlotse' )
		);
	}
}
