<?php

declare(strict_types=1);

namespace UFPE\GoogleOIDC;

use WP_Error;

final class GoogleClient
{
    private const AUTH_URI = 'https://accounts.google.com/o/oauth2/v2/auth';
    private const TOKEN_URI = 'https://oauth2.googleapis.com/token';
    private const JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
    private const ISSUERS = ['accounts.google.com', 'https://accounts.google.com'];
    private const LEEWAY = 60;

    private Config $config;

    public function __construct(Config $config)
    {
        $this->config = $config;
    }

    public function authorizationUrl(string $state, string $nonce): string
    {
        return add_query_arg([
            'response_type' => 'code',
            'client_id' => $this->config->clientId(),
            'redirect_uri' => $this->config->redirectUri(),
            'scope' => 'openid email profile',
            'state' => $state,
            'nonce' => $nonce,
            'hd' => $this->config->allowedDomain(),
            'prompt' => 'select_account',
        ], self::AUTH_URI);
    }

    /**
     * @return array|WP_Error
     */
    public function exchangeCode(string $code)
    {
        $response = wp_remote_post(self::TOKEN_URI, [
            'timeout' => 10,
            'body' => [
                'code' => $code,
                'client_id' => $this->config->clientId(),
                'client_secret' => $this->config->clientSecret(),
                'redirect_uri' => $this->config->redirectUri(),
                'grant_type' => 'authorization_code',
            ],
        ]);

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

        $status = (int) wp_remote_retrieve_response_code($response);
        $payload = json_decode((string) wp_remote_retrieve_body($response), true);

        if ($status < 200 || $status >= 300 || ! is_array($payload) || empty($payload['id_token'])) {
            return new WP_Error('ufpe_google_token_exchange_failed', 'Falha ao trocar o codigo OAuth no Google.');
        }

        return $payload;
    }

    /**
     * @return array|WP_Error
     */
    public function validateIdToken(string $idToken, string $expectedNonce)
    {
        $decoded = $this->decodeJwt($idToken);

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

        [$header, $claims, $signature, $signedPart] = $decoded;

        if (($header['alg'] ?? '') !== 'RS256' || empty($header['kid'])) {
            return new WP_Error('ufpe_google_invalid_jwt_header', 'Cabecalho JWT invalido.');
        }

        $key = $this->publicKeyForKid((string) $header['kid']);

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

        $verified = openssl_verify($signedPart, $signature, $key, OPENSSL_ALGO_SHA256);

        if ($verified !== 1) {
            return new WP_Error('ufpe_google_invalid_signature', 'Assinatura do ID token invalida.');
        }

        return $this->validateClaims($claims, $expectedNonce);
    }

    /**
     * @return array|WP_Error
     */
    private function validateClaims(array $claims, string $expectedNonce)
    {
        $now = time();
        $aud = $claims['aud'] ?? null;
        $iss = (string) ($claims['iss'] ?? '');
        $exp = (int) ($claims['exp'] ?? 0);
        $nonce = (string) ($claims['nonce'] ?? '');
        $hd = strtolower((string) ($claims['hd'] ?? ''));
        $emailVerified = $claims['email_verified'] ?? false;

        if (! in_array($iss, self::ISSUERS, true)) {
            return new WP_Error('ufpe_google_invalid_issuer', 'Emissor do ID token invalido.');
        }

        if (! $this->validAudience($aud, (string) ($claims['azp'] ?? ''))) {
            return new WP_Error('ufpe_google_invalid_audience', 'Audience do ID token nao corresponde ao client_id.');
        }

        if ($exp < ($now - self::LEEWAY)) {
            return new WP_Error('ufpe_google_expired_token', 'ID token expirado.');
        }

        if (! hash_equals($expectedNonce, $nonce)) {
            return new WP_Error('ufpe_google_invalid_nonce', 'Nonce OIDC invalido.');
        }

        if ($hd !== $this->config->allowedDomain()) {
            return new WP_Error('ufpe_google_invalid_domain', 'Conta Google fora do dominio institucional autorizado.');
        }

        if ($emailVerified !== true && $emailVerified !== 'true') {
            return new WP_Error('ufpe_google_unverified_email', 'E-mail Google nao verificado.');
        }

        if (empty($claims['sub']) || empty($claims['email'])) {
            return new WP_Error('ufpe_google_missing_claims', 'ID token sem claims obrigatorios.');
        }

        return $claims;
    }

    /**
     * @param mixed $aud
     */
    private function validAudience($aud, string $azp): bool
    {
        $clientId = $this->config->clientId();

        if (is_array($aud)) {
            if (! in_array($clientId, $aud, true)) {
                return false;
            }

            return count($aud) === 1 || hash_equals($clientId, $azp);
        }

        return hash_equals($clientId, (string) $aud);
    }

    /**
     * @return array|WP_Error
     */
    private function decodeJwt(string $jwt)
    {
        $parts = explode('.', $jwt);

        if (count($parts) !== 3) {
            return new WP_Error('ufpe_google_malformed_jwt', 'JWT malformado.');
        }

        $header = json_decode((string) $this->base64UrlDecode($parts[0]), true);
        $claims = json_decode((string) $this->base64UrlDecode($parts[1]), true);
        $signature = $this->base64UrlDecode($parts[2]);

        if (! is_array($header) || ! is_array($claims) || $signature === false) {
            return new WP_Error('ufpe_google_malformed_jwt', 'JWT nao decodificavel.');
        }

        return [$header, $claims, $signature, $parts[0] . '.' . $parts[1]];
    }

    /**
     * @return string|WP_Error
     */
    private function publicKeyForKid(string $kid)
    {
        $jwks = $this->jwks();

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

        foreach ($jwks['keys'] ?? [] as $jwk) {
            if (($jwk['kid'] ?? '') === $kid) {
                return $this->jwkToPem($jwk);
            }
        }

        delete_transient('ufpe_google_oidc_jwks');
        return new WP_Error('ufpe_google_unknown_kid', 'Chave publica do Google nao encontrada para o token.');
    }

    /**
     * @return array|WP_Error
     */
    private function jwks()
    {
        $cached = get_transient('ufpe_google_oidc_jwks');

        if (is_array($cached)) {
            return $cached;
        }

        $response = wp_remote_get(self::JWKS_URI, ['timeout' => 10]);

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

        $payload = json_decode((string) wp_remote_retrieve_body($response), true);

        if (! is_array($payload) || empty($payload['keys'])) {
            return new WP_Error('ufpe_google_jwks_failed', 'Nao foi possivel obter JWKS do Google.');
        }

        $maxAge = 3600;
        $cacheHeader = wp_remote_retrieve_header($response, 'cache-control');
        $cacheControl = is_array($cacheHeader) ? implode(',', $cacheHeader) : (string) $cacheHeader;

        if (preg_match('/max-age=(\d+)/', $cacheControl, $matches)) {
            $maxAge = max(60, min(86400, (int) $matches[1]));
        }

        set_transient('ufpe_google_oidc_jwks', $payload, $maxAge);

        return $payload;
    }

    /**
     * @return string|WP_Error
     */
    private function jwkToPem(array $jwk)
    {
        $modulus = $this->base64UrlDecode((string) ($jwk['n'] ?? ''));
        $exponent = $this->base64UrlDecode((string) ($jwk['e'] ?? ''));

        if ($modulus === false || $exponent === false) {
            return new WP_Error('ufpe_google_invalid_jwk', 'JWK do Google invalida.');
        }

        $rsaPublicKey = $this->asn1Sequence(
            $this->asn1Integer($modulus) . $this->asn1Integer($exponent)
        );

        $algorithm = $this->asn1Sequence(
            "\x06\x09\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01" . "\x05\x00"
        );

        $subjectPublicKeyInfo = $this->asn1Sequence(
            $algorithm . "\x03" . $this->asn1Length(strlen($rsaPublicKey) + 1) . "\x00" . $rsaPublicKey
        );

        return "-----BEGIN PUBLIC KEY-----\n"
            . chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n")
            . "-----END PUBLIC KEY-----\n";
    }

    /**
     * @return string|false
     */
    private function base64UrlDecode(string $value)
    {
        $padding = strlen($value) % 4;

        if ($padding > 0) {
            $value .= str_repeat('=', 4 - $padding);
        }

        return base64_decode(strtr($value, '-_', '+/'), true);
    }

    private function asn1Sequence(string $value): string
    {
        return "\x30" . $this->asn1Length(strlen($value)) . $value;
    }

    private function asn1Integer(string $value): string
    {
        if ($value === '' || ord($value[0]) > 0x7f) {
            $value = "\x00" . $value;
        }

        return "\x02" . $this->asn1Length(strlen($value)) . $value;
    }

    private function asn1Length(int $length): string
    {
        if ($length < 0x80) {
            return chr($length);
        }

        $bytes = '';

        while ($length > 0) {
            $bytes = chr($length & 0xff) . $bytes;
            $length >>= 8;
        }

        return chr(0x80 | strlen($bytes)) . $bytes;
    }
}
