<?php

namespace MintyPHP\Service\Auth;

use MintyPHP\Http\Request;
use MintyPHP\I18n;

class MicrosoftOidcService
{
    // 120s tolerance on exp/nbf checks to handle minor clock drift between systems.
    private const CLOCK_SKEW = 120;

    public function __construct(
        private readonly TenantSsoService $tenantSsoService,
        private readonly AuthTenantGateway $tenantGateway,
        private readonly OidcHttpGateway $oidcHttpGateway,
        private readonly MicrosoftOidcStateStoreService $stateStore
    ) {
    }

    public function startAuthorization(array $tenant, array $providerConfig): array
    {
        $tenantId = (int) ($tenant['id'] ?? 0);
        if ($tenantId <= 0) {
            return ['ok' => false, 'error' => 'tenant_not_found'];
        }

        $oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
        if (!($oidc['ok'] ?? false)) {
            return ['ok' => false, 'error' => 'oidc_discovery_failed'];
        }
        $metadata = $oidc['config'] ?? [];

        // PKCE flow: store code_verifier in session, send only the SHA-256 challenge to the AS.
        $state = self::randomString(32);
        $nonce = self::randomString(32);
        $codeVerifier = self::randomString(64);
        $codeChallenge = self::base64UrlEncode(hash('sha256', $codeVerifier, true));
        $locale = I18n::$locale ?? I18n::$defaultLocale ?? 'de';
        $redirectUri = appUrl(Request::withLocale('auth/microsoft/callback', $locale));

        $this->stateStore->prune();
        $this->stateStore->store($state, [
            'tenant_id' => $tenantId,
            'nonce' => $nonce,
            'code_verifier' => $codeVerifier,
            'locale' => $locale,
            'redirect_uri' => $redirectUri,
        ]);

        $params = [
            'client_id' => (string) ($providerConfig['client_id'] ?? ''),
            'response_type' => 'code',
            'redirect_uri' => $redirectUri,
            'response_mode' => 'query',
            'scope' => $this->buildAuthorizationScope($providerConfig),
            'state' => $state,
            'nonce' => $nonce,
            'code_challenge' => $codeChallenge,
            'code_challenge_method' => 'S256',
        ];

        $authorizeEndpoint = (string) ($metadata['authorization_endpoint'] ?? '');
        if ($authorizeEndpoint === '') {
            return ['ok' => false, 'error' => 'authorize_endpoint_missing'];
        }

        return [
            'ok' => true,
            'url' => $authorizeEndpoint . '?' . http_build_query($params),
        ];
    }

    public function handleCallback(string $state, string $code): array
    {
        $state = trim($state);
        $code = trim($code);
        if ($state === '' || $code === '') {
            return ['ok' => false, 'error' => 'callback_invalid'];
        }

        $consumeResult = $this->stateStore->consume($state);
        if (!$consumeResult['ok']) {
            return ['ok' => false, 'error' => (string) ($consumeResult['error'] ?? 'state_invalid')];
        }

        $entry = is_array($consumeResult['entry'] ?? null) ? $consumeResult['entry'] : [];
        $tenantId = (int) ($entry['tenant_id'] ?? 0);
        $tenant = $this->tenantGateway->findById($tenantId);
        if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') {
            return ['ok' => false, 'error' => 'tenant_not_found'];
        }

        $configResult = $this->tenantSsoService->getEffectiveMicrosoftProviderConfig($tenant);
        if (!($configResult['ok'] ?? false)) {
            return ['ok' => false, 'error' => (string) ($configResult['error'] ?? 'sso_config_invalid')];
        }
        $providerConfig = $configResult['config'] ?? [];

        $oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? ''));
        if (!($oidc['ok'] ?? false)) {
            return ['ok' => false, 'error' => 'oidc_discovery_failed'];
        }
        $metadata = $oidc['config'] ?? [];

        $tokenEndpoint = (string) ($metadata['token_endpoint'] ?? '');
        if ($tokenEndpoint === '') {
            return ['ok' => false, 'error' => 'token_endpoint_missing'];
        }

        $tokenResponse = $this->oidcHttpGateway->call('POST', $tokenEndpoint, [
            'grant_type' => 'authorization_code',
            'client_id' => (string) ($providerConfig['client_id'] ?? ''),
            'client_secret' => (string) ($providerConfig['client_secret'] ?? ''),
            'code' => $code,
            'redirect_uri' => (string) ($entry['redirect_uri'] ?? ''),
            'code_verifier' => (string) ($entry['code_verifier'] ?? ''),
        ], [
            'Accept' => 'application/json',
        ]);

        if ((int) ($tokenResponse['status'] ?? 0) < 200 || (int) ($tokenResponse['status'] ?? 0) >= 300) {
            return ['ok' => false, 'error' => 'token_exchange_failed'];
        }

        $tokenData = json_decode((string) ($tokenResponse['data'] ?? ''), true);
        if (!is_array($tokenData)) {
            return ['ok' => false, 'error' => 'token_response_invalid'];
        }
        $idToken = trim((string) ($tokenData['id_token'] ?? ''));
        if ($idToken === '') {
            return ['ok' => false, 'error' => 'id_token_missing'];
        }
        $accessToken = trim((string) ($tokenData['access_token'] ?? ''));

        $validated = $this->validateIdToken(
            $idToken,
            $providerConfig,
            $metadata,
            (string) ($entry['nonce'] ?? '')
        );
        if (!($validated['ok'] ?? false)) {
            return ['ok' => false, 'error' => (string) ($validated['error'] ?? 'id_token_invalid')];
        }
        $claims = $validated['claims'] ?? [];

        $email = self::extractEmailFromClaims($claims);
        if ($email === '') {
            return ['ok' => false, 'error' => 'email_missing'];
        }

        $allowedDomainsCsv = (string) ($providerConfig['allowed_domains'] ?? '');
        $allowedDomains = array_values(array_filter(array_map('trim', explode(',', $allowedDomainsCsv))));
        if (!$this->tenantSsoService->isEmailDomainAllowed($email, $allowedDomains)) {
            return ['ok' => false, 'error' => 'email_domain_not_allowed'];
        }

        $syncEnabled = !empty($providerConfig['sync_profile_on_login']);
        $syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
        if ($syncEnabled && !$syncFields) {
            $syncFields = $this->tenantSsoService->defaultProfileSyncFields();
        }
        $graphProfile = [];
        if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields) && $accessToken !== '') {
            $graph = $this->fetchMicrosoftGraphProfile($accessToken);
            if (!empty($graph['ok'])) {
                $graphProfile = is_array($graph['profile'] ?? null) ? $graph['profile'] : [];
            }
        }
        $graphAvatar = [];
        if ($syncEnabled && in_array('avatar', $syncFields, true) && $accessToken !== '') {
            $avatar = $this->fetchMicrosoftGraphAvatar($accessToken);
            if (!empty($avatar['ok'])) {
                $graphAvatar = is_array($avatar['avatar'] ?? null) ? $avatar['avatar'] : [];
            }
        }

        return [
            'ok' => true,
            'tenant' => $tenant,
            'locale' => (string) ($entry['locale'] ?? (I18n::$locale ?? I18n::$defaultLocale ?? 'de')),
            'graph_profile' => $graphProfile,
            'graph_avatar' => $graphAvatar,
            'sync_config' => [
                'enabled' => $syncEnabled,
                'fields' => $syncFields,
            ],
            'claims' => [
                'tid' => (string) ($claims['tid'] ?? ''),
                'oid' => (string) ($claims['oid'] ?? ''),
                'issuer' => (string) ($claims['iss'] ?? ''),
                'subject' => (string) ($claims['sub'] ?? ''),
                'email' => $email,
                'name' => trim((string) ($claims['name'] ?? '')),
                'given_name' => trim((string) ($claims['given_name'] ?? '')),
                'family_name' => trim((string) ($claims['family_name'] ?? '')),
                'preferred_language' => trim((string) ($claims['preferred_language'] ?? '')),
                'graph_given_name' => trim((string) ($graphProfile['given_name'] ?? '')),
                'graph_family_name' => trim((string) ($graphProfile['family_name'] ?? '')),
                'graph_phone' => trim((string) ($graphProfile['phone'] ?? '')),
                'graph_mobile' => trim((string) ($graphProfile['mobile'] ?? '')),
                'graph_avatar_mime' => trim((string) ($graphAvatar['mime'] ?? '')),
                'graph_avatar_data_base64' => trim((string) ($graphAvatar['data_base64'] ?? '')),
                'sync_profile_on_login' => $syncEnabled ? 1 : 0,
                'sync_profile_fields' => $syncFields,
            ],
        ];
    }

    private function buildAuthorizationScope(array $providerConfig): string
    {
        $scopes = ['openid', 'profile', 'email'];
        $syncEnabled = !empty($providerConfig['sync_profile_on_login']);
        $syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []);
        if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields)) {
            $scopes[] = 'User.Read';
        }
        $scopes = array_values(array_unique($scopes));
        return implode(' ', $scopes);
    }

    private function fetchOpenIdConfiguration(string $authority): array
    {
        $authority = trim($authority);
        if ($authority === '') {
            return ['ok' => false];
        }
        $url = rtrim($authority, '/') . '/.well-known/openid-configuration';
        $response = $this->oidcHttpGateway->call('GET', $url, '', ['Accept' => 'application/json']);
        if ((int) ($response['status'] ?? 0) < 200 || (int) ($response['status'] ?? 0) >= 300) {
            return ['ok' => false];
        }
        $config = json_decode((string) ($response['data'] ?? ''), true);
        if (!is_array($config)) {
            return ['ok' => false];
        }
        return ['ok' => true, 'config' => $config];
    }

    private function validateIdToken(
        string $idToken,
        array $providerConfig,
        array $metadata,
        string $expectedNonce
    ): array {
        $parts = explode('.', $idToken);
        if (count($parts) !== 3) {
            return ['ok' => false, 'error' => 'id_token_format_invalid'];
        }

        $header = json_decode(self::base64UrlDecode($parts[0]), true);
        $claims = json_decode(self::base64UrlDecode($parts[1]), true);
        $signature = self::base64UrlDecode($parts[2]);
        if (!is_array($header) || !is_array($claims) || $signature === '') {
            return ['ok' => false, 'error' => 'id_token_parse_failed'];
        }

        $alg = strtoupper(trim((string) ($header['alg'] ?? '')));
        if ($alg !== 'RS256') {
            return ['ok' => false, 'error' => 'id_token_alg_invalid'];
        }

        $jwksUri = trim((string) ($metadata['jwks_uri'] ?? ''));
        if ($jwksUri === '') {
            return ['ok' => false, 'error' => 'jwks_uri_missing'];
        }

        $jwksResponse = $this->oidcHttpGateway->call('GET', $jwksUri, '', ['Accept' => 'application/json']);
        if ((int) ($jwksResponse['status'] ?? 0) < 200 || (int) ($jwksResponse['status'] ?? 0) >= 300) {
            return ['ok' => false, 'error' => 'jwks_fetch_failed'];
        }
        $jwks = json_decode((string) ($jwksResponse['data'] ?? ''), true);
        if (!is_array($jwks) || !is_array($jwks['keys'] ?? null)) {
            return ['ok' => false, 'error' => 'jwks_invalid'];
        }

        $kid = (string) ($header['kid'] ?? '');
        $jwk = null;
        foreach ($jwks['keys'] as $key) {
            if (!is_array($key)) {
                continue;
            }
            if ((string) ($key['kid'] ?? '') === $kid) {
                $jwk = $key;
                break;
            }
        }
        if (!$jwk) {
            return ['ok' => false, 'error' => 'jwk_not_found'];
        }

        $pem = self::jwkToPem($jwk);
        if ($pem === null) {
            return ['ok' => false, 'error' => 'jwk_invalid'];
        }

        $verified = openssl_verify(
            $parts[0] . '.' . $parts[1],
            $signature,
            $pem,
            OPENSSL_ALGO_SHA256
        );
        if ($verified !== 1) {
            return ['ok' => false, 'error' => 'id_token_signature_invalid'];
        }

        $now = time();
        $exp = (int) ($claims['exp'] ?? 0);
        if ($exp <= 0 || $exp < ($now - self::CLOCK_SKEW)) {
            return ['ok' => false, 'error' => 'id_token_expired'];
        }

        $nbf = (int) ($claims['nbf'] ?? 0);
        if ($nbf > 0 && $nbf > ($now + self::CLOCK_SKEW)) {
            return ['ok' => false, 'error' => 'id_token_not_yet_valid'];
        }

        $audience = $claims['aud'] ?? '';
        $clientId = (string) ($providerConfig['client_id'] ?? '');
        $audValid = false;
        if (is_array($audience)) {
            $audValid = in_array($clientId, $audience, true);
        } else {
            $audValid = (string) $audience === $clientId;
        }
        if (!$audValid) {
            return ['ok' => false, 'error' => 'id_token_aud_invalid'];
        }

        if ((string) ($claims['nonce'] ?? '') !== $expectedNonce) {
            return ['ok' => false, 'error' => 'id_token_nonce_invalid'];
        }

        $tid = strtolower((string) ($claims['tid'] ?? ''));
        $expectedTid = strtolower((string) ($providerConfig['tenant_id'] ?? ''));
        if ($expectedTid === '' || $tid !== $expectedTid) {
            return ['ok' => false, 'error' => 'id_token_tid_invalid'];
        }

        $iss = strtolower((string) ($claims['iss'] ?? ''));
        $issuerTemplate = strtolower((string) ($metadata['issuer'] ?? ''));
        if ($issuerTemplate !== '') {
            $expectedIssuer = str_replace('{tenantid}', $tid, $issuerTemplate);
            if ($iss !== $expectedIssuer) {
                return ['ok' => false, 'error' => 'id_token_iss_invalid'];
            }
        }

        if ((string) ($claims['oid'] ?? '') === '' || (string) ($claims['sub'] ?? '') === '') {
            return ['ok' => false, 'error' => 'id_token_identity_invalid'];
        }

        return ['ok' => true, 'claims' => $claims];
    }

    private static function extractEmailFromClaims(array $claims): string
    {
        $email = trim((string) ($claims['email'] ?? ''));
        if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return strtolower($email);
        }
        $upn = trim((string) ($claims['preferred_username'] ?? ''));
        if ($upn !== '' && filter_var($upn, FILTER_VALIDATE_EMAIL)) {
            return strtolower($upn);
        }
        return '';
    }

    private function fetchMicrosoftGraphProfile(string $accessToken): array
    {
        $url = 'https://graph.microsoft.com/v1.0/me?$select=givenName,surname,mobilePhone,businessPhones';
        $response = $this->oidcHttpGateway->call('GET', $url, '', [
            'Accept' => 'application/json',
            'Authorization' => 'Bearer ' . $accessToken,
        ]);
        $status = (int) ($response['status'] ?? 0);
        if ($status < 200 || $status >= 300) {
            return ['ok' => false];
        }
        $data = json_decode((string) ($response['data'] ?? ''), true);
        if (!is_array($data)) {
            return ['ok' => false];
        }

        $phone = '';
        $businessPhones = $data['businessPhones'] ?? [];
        if (is_array($businessPhones) && isset($businessPhones[0])) {
            $phone = trim((string) $businessPhones[0]);
        }

        return [
            'ok' => true,
            'profile' => [
                'given_name' => trim((string) ($data['givenName'] ?? '')),
                'family_name' => trim((string) ($data['surname'] ?? '')),
                'mobile' => trim((string) ($data['mobilePhone'] ?? '')),
                'phone' => $phone,
            ],
        ];
    }

    private function fetchMicrosoftGraphAvatar(string $accessToken): array
    {
        $url = 'https://graph.microsoft.com/v1.0/me/photo/$value';
        $response = $this->oidcHttpGateway->call('GET', $url, '', [
            'Accept' => 'image/*',
            'Authorization' => 'Bearer ' . $accessToken,
        ]);
        $status = (int) ($response['status'] ?? 0);
        if ($status < 200 || $status >= 300) {
            return ['ok' => false];
        }

        $data = $response['data'] ?? null;
        if (!is_string($data) || $data === '') {
            return ['ok' => false];
        }

        $contentType = self::headerValue($response, 'Content-Type');
        $contentType = strtolower(trim(explode(';', $contentType, 2)[0]));
        if (!in_array($contentType, ['image/jpeg', 'image/png', 'image/webp'], true)) {
            return ['ok' => false];
        }

        return [
            'ok' => true,
            'avatar' => [
                'mime' => $contentType,
                'data_base64' => base64_encode($data),
            ],
        ];
    }

    private static function headerValue(array $response, string $name): string
    {
        $headers = is_array($response['headers'] ?? null) ? $response['headers'] : [];
        foreach ($headers as $key => $value) {
            if (strcasecmp((string) $key, $name) === 0) {
                return is_string($value) ? $value : '';
            }
        }
        return '';
    }

    private static function randomString(int $bytes): string
    {
        return self::base64UrlEncode(random_bytes($bytes));
    }

    private static function base64UrlEncode(string $value): string
    {
        return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
    }

    private static function base64UrlDecode(string $value): string
    {
        $value = strtr($value, '-_', '+/');
        $pad = strlen($value) % 4;
        if ($pad > 0) {
            $value .= str_repeat('=', 4 - $pad);
        }
        $decoded = base64_decode($value, true);
        return is_string($decoded) ? $decoded : '';
    }

    // Builds a PEM public key from a JWK RSA key (n, e) by constructing the ASN.1 DER structure manually.
    // PHP's openssl extension doesn't support importing JWK directly, so we encode it ourselves.
    private static function jwkToPem(array $jwk): ?string
    {
        $n = self::base64UrlDecode((string) ($jwk['n'] ?? ''));
        $e = self::base64UrlDecode((string) ($jwk['e'] ?? ''));
        if ($n === '' || $e === '') {
            return null;
        }

        $modulus = self::asn1Integer($n);
        $publicExponent = self::asn1Integer($e);
        $rsaPublicKey = self::asn1Sequence($modulus . $publicExponent);

        $algo = self::asn1Sequence(
            self::asn1ObjectIdentifier("\x2A\x86\x48\x86\xF7\x0D\x01\x01\x01") .
            self::asn1Null()
        );
        $bitString = "\x03" . self::asn1Length(strlen($rsaPublicKey) + 1) . "\x00" . $rsaPublicKey;
        $subjectPublicKeyInfo = self::asn1Sequence($algo . $bitString);

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

    private static function asn1Integer(string $bytes): string
    {
        if (ord($bytes[0]) > 0x7F) {
            $bytes = "\x00" . $bytes;
        }
        return "\x02" . self::asn1Length(strlen($bytes)) . $bytes;
    }

    private static function asn1Sequence(string $bytes): string
    {
        return "\x30" . self::asn1Length(strlen($bytes)) . $bytes;
    }

    private static function asn1ObjectIdentifier(string $bytes): string
    {
        return "\x06" . self::asn1Length(strlen($bytes)) . $bytes;
    }

    private static function asn1Null(): string
    {
        return "\x05\x00";
    }

    private static function asn1Length(int $length): string
    {
        if ($length < 128) {
            return chr($length);
        }
        $temp = ltrim(pack('N', $length), "\x00");
        return chr(0x80 | strlen($temp)) . $temp;
    }
}
