<?php

namespace IMATHUZH\Qfq\Core\Imap;

use IMATHUZH\Qfq\Core\Helper\Path;

/**
 * Class OAuth2TokenManager
 *
 * Delivers a valid OAuth2 access token for IMAP SASL XOAUTH2 authentication (e.g. Outlook/Office365,
 * where basic auth via username/password is disabled).
 *
 * Initial authorization (browser login incl. MFA, needed ONCE per account) happens from the QFQ
 * emailSync report: startDeviceAuthorization() requests a device code and returns the login
 * instructions (URL + user code); after the admin completed the browser login,
 * pollDeviceAuthorization() fetches the tokens (one poll per call, no background process).
 *
 * Afterwards getAccessToken() keeps the token fresh without any user interaction: if the stored
 * access token is expired, it is renewed via the OAuth2 refresh token (single POST to the
 * provider's token endpoint) and the token file is updated in place. Regular usage keeps the
 * refresh token rolling, so no further interactive login is needed.
 *
 * The token file is plain JSON (mode 0600) in the well-known mutt oauth2 token format, so a token
 * file created by the upstream mutt_oauth2.py tool can be used as well. The pending device
 * authorization state is kept in the same file as extra keys.
 *
 * @package qfq
 */
class OAuth2TokenManager {

    // OAuth2 provider registrations; every value can be overridden per token file (see getRegistration()).
    const REGISTRATIONS = [
        IMAP_OAUTH2_REGISTRATION_MICROSOFT => [
            IMAP_OAUTH2_KEY_TOKEN_ENDPOINT => IMAP_OAUTH2_TOKEN_ENDPOINT_MICROSOFT,
            IMAP_OAUTH2_KEY_DEVICECODE_ENDPOINT => IMAP_OAUTH2_DEVICECODE_ENDPOINT_MICROSOFT,
            IMAP_OAUTH2_KEY_CLIENT_ID => IMAP_OAUTH2_CLIENT_ID_MICROSOFT,
            IMAP_OAUTH2_KEY_CLIENT_SECRET => '',
            IMAP_OAUTH2_KEY_TENANT => 'common',
            IMAP_OAUTH2_KEY_SCOPE => IMAP_OAUTH2_SCOPE_MICROSOFT,
        ],
        IMAP_OAUTH2_REGISTRATION_GOOGLE => [
            IMAP_OAUTH2_KEY_TOKEN_ENDPOINT => IMAP_OAUTH2_TOKEN_ENDPOINT_GOOGLE,
            IMAP_OAUTH2_KEY_DEVICECODE_ENDPOINT => IMAP_OAUTH2_DEVICECODE_ENDPOINT_GOOGLE,
            IMAP_OAUTH2_KEY_CLIENT_ID => '',
            IMAP_OAUTH2_KEY_CLIENT_SECRET => '',
            IMAP_OAUTH2_KEY_SCOPE => IMAP_OAUTH2_SCOPE_GOOGLE,
        ],
    ];

    // Device code lifetime fallback if the provider response carries no 'expires_in' (MS: 900s).
    const DEVICE_CODE_EXPIRES_IN_DEFAULT = 900;

    /**
     * Returns a valid access token from the given token file, refreshing it first if necessary.
     *
     * The whole read-check-refresh-write cycle runs under an exclusive lock: concurrent
     * processes (email-sync daemon + report action) won't refresh twice or clobber the rotated
     * refresh token.
     *
     * @param string $pathFileNameToken - absolute or relative to app
     * @return string
     * @throws \UserReportException
     */
    public function getAccessToken(string $pathFileNameToken): string {

        $pathFileNameAbsolute = self::resolveTokenPath($pathFileNameToken);
        $fp = $this->openTokenFileLocked($pathFileNameAbsolute, false);

        try {
            $token = $this->parseTokenFile((string)stream_get_contents($fp), $pathFileNameAbsolute);

            $isAppOnly = self::isAppOnly($token);

            // App-only needs no stored tokens (it fetches on demand via client_secret); the
            // delegated flow needs a refresh or access token from a prior browser authorization.
            if (!$isAppOnly && empty($token[IMAP_OAUTH2_KEY_REFRESH_TOKEN]) && empty($token[IMAP_OAUTH2_KEY_ACCESS_TOKEN])) {
                throw new \UserReportException("OAuth2 token file contains no tokens: '$pathFileNameAbsolute'. "
                    . "Authorize the account first via the emailSync report (key button).", ERROR_IMAP);
            }

            if (!self::isAccessTokenValid($token)) {
                $token = $isAppOnly
                    ? $this->fetchClientCredentialsToken($token, $pathFileNameAbsolute)
                    : $this->refreshAccessToken($token, $pathFileNameAbsolute);
                $this->writeTokenFile($fp, $token, $pathFileNameAbsolute);
            }

            return $token[IMAP_OAUTH2_KEY_ACCESS_TOKEN];

        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    /**
     * Starts an OAuth2 device code authorization (first step of the browser based flow).
     *
     * Requests a device/user code pair from the provider and stores the pending device code in the
     * token file (created if missing). Existing access/refresh tokens are preserved until the new
     * authorization completes via pollDeviceAuthorization() - an accidental click doesn't break a
     * working account.
     *
     * @param string $pathFileNameToken - absolute or relative to app
     * @param string $email - account e-mail address (login hint, stored in the token file)
     * @param string $registrationName - IMAP_OAUTH2_REGISTRATION_MICROSOFT | ..._GOOGLE
     * @return array - ['user_code' =>, 'verification_uri' =>, 'message' =>, 'expires_in' =>]
     * @throws \UserReportException
     */
    public function startDeviceAuthorization(string $pathFileNameToken, string $email, string $registrationName = IMAP_OAUTH2_REGISTRATION_MICROSOFT): array {

        $pathFileNameAbsolute = self::resolveTokenPath($pathFileNameToken);
        $fp = $this->openTokenFileLocked($pathFileNameAbsolute, true);

        try {
            // Lenient read: a fresh or empty file starts from scratch, existing keys are kept.
            $token = json_decode((string)stream_get_contents($fp), true);
            if (!is_array($token)) {
                $token = [];
            }

            // App-only accounts are not authorized via the browser - refuse, so a stray key-button
            // click does not overwrite the client_credentials config with a device-code flow.
            if (self::isAppOnly($token)) {
                throw new \UserReportException("This is an app-only account (client_credentials) - "
                    . "no browser authorization needed. Its token is fetched automatically from the "
                    . "app credentials in the token file.", ERROR_IMAP);
            }

            // Existing working tokens survive until the new flow completes - tell the UI, so an
            // accidental click is recognizable as harmless.
            $wasAuthorized = !empty($token[IMAP_OAUTH2_KEY_REFRESH_TOKEN]) || self::isAccessTokenValid($token);

            $token[IMAP_OAUTH2_KEY_REGISTRATION] = $registrationName;
            $token[IMAP_OAUTH2_KEY_AUTHFLOW] = IMAP_OAUTH2_AUTHFLOW_DEVICECODE;
            $token[IMAP_OAUTH2_KEY_EMAIL] = $email;
            $token += [IMAP_OAUTH2_KEY_ACCESS_TOKEN => '', IMAP_OAUTH2_KEY_ACCESS_TOKEN_EXPIRATION => '', IMAP_OAUTH2_KEY_REFRESH_TOKEN => ''];

            $registration = self::getRegistration($token);
            if (empty($registration[IMAP_OAUTH2_KEY_DEVICECODE_ENDPOINT])) {
                throw new \UserReportException("OAuth2 registration '$registrationName' has no device code endpoint.", ERROR_IMAP);
            }

            $params = self::buildBaseParams($registration);
            $params['scope'] = $registration[IMAP_OAUTH2_KEY_SCOPE];

            $response = $this->postTokenEndpoint($registration[IMAP_OAUTH2_KEY_DEVICECODE_ENDPOINT], $params);

            if (isset($response['error'])) {
                throw new \UserReportException("OAuth2 device code request failed: " . $response['error']
                    . (isset($response['error_description']) ? ' - ' . $response['error_description'] : ''), ERROR_IMAP);
            }
            if (empty($response['device_code']) || empty($response['user_code'])) {
                throw new \UserReportException("OAuth2 device code response misses 'device_code'/'user_code'.", ERROR_IMAP);
            }

            $expiresIn = (int)($response['expires_in'] ?? self::DEVICE_CODE_EXPIRES_IN_DEFAULT);
            $token[IMAP_OAUTH2_KEY_DEVICE_CODE] = $response['device_code'];
            $token[IMAP_OAUTH2_KEY_DEVICE_CODE_EXPIRATION] = self::expirationFromNow($expiresIn);

            $this->writeTokenFile($fp, $token, $pathFileNameAbsolute);

            return [
                'user_code' => $response['user_code'],
                // Microsoft: 'verification_uri', Google: 'verification_url'
                'verification_uri' => $response['verification_uri'] ?? ($response['verification_url'] ?? ''),
                'message' => $response['message'] ?? '',
                'expires_in' => $expiresIn,
                'was_authorized' => $wasAuthorized,
            ];

        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    /**
     * Second step of the browser based flow: one single poll for the pending device authorization.
     *
     * Call again (e.g. via button click) until the admin completed the browser login. Flow level
     * outcomes are returned as status - exceptions are reserved for infrastructure problems
     * (file missing, endpoint unreachable).
     *
     * @param string $pathFileNameToken - absolute or relative to app
     * @return array - ['status' => 'ok'|'pending'|'error', 'message' => string]
     * @throws \UserReportException
     */
    public function pollDeviceAuthorization(string $pathFileNameToken): array {

        $pathFileNameAbsolute = self::resolveTokenPath($pathFileNameToken);
        $fp = $this->openTokenFileLocked($pathFileNameAbsolute, false);

        try {
            $token = $this->parseTokenFile((string)stream_get_contents($fp), $pathFileNameAbsolute);

            if (empty($token[IMAP_OAUTH2_KEY_DEVICE_CODE])) {
                if (!empty($token[IMAP_OAUTH2_KEY_REFRESH_TOKEN]) || self::isAccessTokenValid($token)) {
                    return ['status' => 'ok', 'message' => 'Account is already authorized.'];
                }
                return ['status' => 'error', 'message' => 'No pending authorization - start the authorization first.'];
            }

            // Device code expired before the login was completed: force a restart.
            $deviceCodeExpiration = $token[IMAP_OAUTH2_KEY_DEVICE_CODE_EXPIRATION] ?? '';
            if ($deviceCodeExpiration === '' || new \DateTimeImmutable($deviceCodeExpiration) < new \DateTimeImmutable()) {
                $this->clearDeviceCode($token);
                $this->writeTokenFile($fp, $token, $pathFileNameAbsolute);
                return ['status' => 'error', 'message' => 'The device code expired - restart the authorization.'];
            }

            $registration = self::getRegistration($token);
            $params = self::buildBaseParams($registration);
            $params['grant_type'] = 'urn:ietf:params:oauth:grant-type:device_code';
            $params['device_code'] = $token[IMAP_OAUTH2_KEY_DEVICE_CODE];

            $response = $this->postTokenEndpoint($registration[IMAP_OAUTH2_KEY_TOKEN_ENDPOINT], $params);

            if (isset($response['error'])) {
                // Not a failure: the admin simply has not finished the browser login yet.
                if (in_array($response['error'], ['authorization_pending', 'slow_down'], true)) {
                    return ['status' => 'pending', 'message' => 'Browser login not completed yet - finish the login, then check again.'];
                }

                // declined / expired_token / bad_verification_code / ...: authorization is dead.
                $this->clearDeviceCode($token);
                $this->writeTokenFile($fp, $token, $pathFileNameAbsolute);
                return ['status' => 'error', 'message' => 'Authorization failed: ' . $response['error']
                    . (isset($response['error_description']) ? ' - ' . $response['error_description'] : '')
                    . '. Restart the authorization.'];
            }

            if (empty($response['access_token']) || empty($response['expires_in'])) {
                throw new \UserReportException("OAuth2 token endpoint response misses 'access_token'/'expires_in'.", ERROR_IMAP);
            }

            $this->applyTokenResponse($token, $response);
            $this->clearDeviceCode($token);
            $this->writeTokenFile($fp, $token, $pathFileNameAbsolute);

            return ['status' => 'ok', 'message' => 'Authorization completed - tokens stored.'];

        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    /**
     * Resolves the token path (absolute, or relative to the app directory) to an absolute path.
     *
     * @param string $pathFileNameToken
     * @return string
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public static function resolveTokenPath(string $pathFileNameToken): string {

        $pathFileNameToken = trim($pathFileNameToken);
        if ($pathFileNameToken === '') {
            throw new \UserReportException("OAuth2 token file path is empty. Set the account password to '"
                . IMAP_OAUTH2_TOKEN_PREFIX . "<path to token file>', e.g. '"
                . IMAP_OAUTH2_TOKEN_PREFIX . "/var/qfq-token/account.token'.", ERROR_IMAP);
        }

        // Resolve app path lazily: absolute token paths have to work even where the app path
        // is not detectable (e.g. CLI scripts started outside the app directory).
        if ($pathFileNameToken[0] === '/') {
            return $pathFileNameToken;
        }
        return Path::joinIfNotAbsolute(Path::absoluteApp(), $pathFileNameToken);
    }

    /**
     * Opens the token file and acquires an exclusive lock. With $createIfMissing a new file is
     * created with mode 0600 (the token file holds multi-use bearer tokens).
     *
     * @param string $pathFileNameAbsolute
     * @param bool $createIfMissing
     * @return resource
     * @throws \UserReportException
     */
    private function openTokenFileLocked(string $pathFileNameAbsolute, bool $createIfMissing) {

        if (is_dir($pathFileNameAbsolute)) {
            throw new \UserReportException("OAuth2 token file path points to a directory: '$pathFileNameAbsolute'. "
                . "Configure a file, e.g. '" . IMAP_OAUTH2_TOKEN_PREFIX . "$pathFileNameAbsolute/account.token'.", ERROR_IMAP);
        }

        $createNew = $createIfMissing && !file_exists($pathFileNameAbsolute);

        $fp = @fopen($pathFileNameAbsolute, $createIfMissing ? 'c+' : 'r+');
        if (false === $fp) {
            throw new \UserReportException("OAuth2 token file not found or not writable: '$pathFileNameAbsolute'. "
                . ($createIfMissing
                    ? "Check that the directory exists and is writable for the PHP user."
                    : "Authorize the account first via the emailSync report (key button) "
                    . "and make the file read/writable for the PHP user."), ERROR_IMAP);
        }

        if ($createNew) {
            @chmod($pathFileNameAbsolute, 0600);
        }

        if (!flock($fp, LOCK_EX)) {
            fclose($fp);
            throw new \UserReportException("Can't lock OAuth2 token file: '$pathFileNameAbsolute'", ERROR_IMAP);
        }

        return $fp;
    }

    /**
     * @param string $content
     * @param string $pathFileNameAbsolute - just for error messages
     * @return array
     * @throws \UserReportException
     */
    private function parseTokenFile(string $content, string $pathFileNameAbsolute): array {

        $token = json_decode($content, true);
        if (!is_array($token)) {
            throw new \UserReportException("OAuth2 token file is not valid JSON: '$pathFileNameAbsolute'. "
                . "QFQ needs a plain (unencrypted) JSON token file - re-authorize the account via the "
                . "emailSync report (key button) to recreate it.", ERROR_IMAP);
        }

        return $token;
    }

    /**
     * True if a stored access token exists and is valid for at least IMAP_OAUTH2_EXPIRY_MARGIN_SECONDS.
     *
     * The expiration is stored as naive local time (Python datetime.isoformat(), e.g.
     * '2026-07-06T15:04:05.123456') and therefore compared against PHP local time.
     *
     * @param array $token
     * @return bool
     */
    public static function isAccessTokenValid(array $token): bool {

        if (empty($token[IMAP_OAUTH2_KEY_ACCESS_TOKEN]) || empty($token[IMAP_OAUTH2_KEY_ACCESS_TOKEN_EXPIRATION])) {
            return false;
        }

        try {
            $expiration = new \DateTimeImmutable($token[IMAP_OAUTH2_KEY_ACCESS_TOKEN_EXPIRATION]);
        } catch (\Exception $e) {
            return false;
        }

        return $expiration > new \DateTimeImmutable('+' . IMAP_OAUTH2_EXPIRY_MARGIN_SECONDS . ' seconds');
    }

    /**
     * Renews the access token via the stored refresh token and returns the updated token array.
     *
     * @param array $token
     * @param string $pathFileNameAbsolute - just for error messages
     * @return array
     * @throws \UserReportException
     */
    private function refreshAccessToken(array $token, string $pathFileNameAbsolute): array {

        if (empty($token[IMAP_OAUTH2_KEY_REFRESH_TOKEN])) {
            throw new \UserReportException("OAuth2 access token expired and no refresh token given: '$pathFileNameAbsolute'. "
                . "Authorize the account again via the emailSync report (key button).", ERROR_IMAP);
        }

        $registration = self::getRegistration($token);
        $params = self::buildRefreshParams($registration, $token[IMAP_OAUTH2_KEY_REFRESH_TOKEN]);

        $response = $this->requestTokenRefresh($registration[IMAP_OAUTH2_KEY_TOKEN_ENDPOINT], $params, $pathFileNameAbsolute);

        $this->applyTokenResponse($token, $response);

        return $token;
    }

    /**
     * True if the token file is configured for the app-only (client credentials) flow.
     *
     * @param array $token
     * @return bool
     */
    public static function isAppOnly(array $token): bool {
        return ($token[IMAP_OAUTH2_KEY_AUTHFLOW] ?? '') === IMAP_OAUTH2_AUTHFLOW_CLIENT_CREDENTIALS;
    }

    /**
     * Fetches an access token via grant_type=client_credentials (app-only, no user, no browser).
     *
     * The token file provides client_id + client_secret + tenant (own Azure app with
     * IMAP.AccessAsApp + per-mailbox FullAccess). No refresh token exists - on expiry a fresh
     * token is simply requested again with the client secret.
     *
     * @param array $token
     * @param string $pathFileNameAbsolute - just for error messages
     * @return array
     * @throws \UserReportException
     */
    private function fetchClientCredentialsToken(array $token, string $pathFileNameAbsolute): array {

        $registration = self::getRegistration($token);

        if (empty($registration[IMAP_OAUTH2_KEY_CLIENT_SECRET])) {
            throw new \UserReportException("App-only OAuth2 token file '$pathFileNameAbsolute' has no 'client_secret'. "
                . "Add the Azure app's client_id, client_secret and tenant.", ERROR_IMAP);
        }

        // client_credentials requires the tenant-specific endpoint (not '/common'). An explicit
        // 'token_endpoint' in the token file wins; otherwise build it from the tenant.
        $tenant = $registration[IMAP_OAUTH2_KEY_TENANT] ?? '';
        if (!empty($token[IMAP_OAUTH2_KEY_TOKEN_ENDPOINT])) {
            $tokenEndpoint = $token[IMAP_OAUTH2_KEY_TOKEN_ENDPOINT];
        } else {
            if ($tenant === '' || $tenant === 'common') {
                throw new \UserReportException("App-only OAuth2 token file '$pathFileNameAbsolute' needs a specific "
                    . "'tenant' (the Azure directory/tenant id), not 'common'.", ERROR_IMAP);
            }
            $tokenEndpoint = sprintf(IMAP_OAUTH2_TOKEN_ENDPOINT_MICROSOFT_TENANT_TEMPLATE, $tenant);
        }

        $params = [
            'client_id' => $registration[IMAP_OAUTH2_KEY_CLIENT_ID],
            'client_secret' => $registration[IMAP_OAUTH2_KEY_CLIENT_SECRET],
            // '.default' unless the token file overrides the scope (getRegistration applied that).
            'scope' => !empty($token[IMAP_OAUTH2_KEY_SCOPE]) ? $registration[IMAP_OAUTH2_KEY_SCOPE] : IMAP_OAUTH2_SCOPE_CLIENT_CREDENTIALS_MICROSOFT,
            'grant_type' => 'client_credentials',
        ];

        $response = $this->postTokenEndpoint($tokenEndpoint, $params);

        if (isset($response['error'])) {
            throw new \UserReportException("App-only OAuth2 token request failed ('$pathFileNameAbsolute'): " . $response['error']
                . (isset($response['error_description']) ? ' - ' . $response['error_description'] : '')
                . ". Check the Azure app credentials (client_id/secret/tenant), the IMAP.AccessAsApp permission "
                . "(admin consent) and the mailbox FullAccess for the service principal.", ERROR_IMAP);
        }
        if (empty($response['access_token']) || empty($response['expires_in'])) {
            throw new \UserReportException("App-only OAuth2 token response misses 'access_token'/'expires_in': $tokenEndpoint", ERROR_IMAP);
        }

        // No refresh token in client_credentials - applyTokenResponse only sets access token + expiry.
        $this->applyTokenResponse($token, $response);

        return $token;
    }

    /**
     * Applies a token endpoint response; the refresh token might rotate and is then replaced.
     *
     * @param array $token - modified in place
     * @param array $response - token endpoint response with 'access_token' and 'expires_in'
     * @return void
     */
    private function applyTokenResponse(array &$token, array $response): void {
        $token[IMAP_OAUTH2_KEY_ACCESS_TOKEN] = $response['access_token'];
        $token[IMAP_OAUTH2_KEY_ACCESS_TOKEN_EXPIRATION] = self::expirationFromNow((int)$response['expires_in']);
        if (!empty($response['refresh_token'])) {
            $token[IMAP_OAUTH2_KEY_REFRESH_TOKEN] = $response['refresh_token'];
        }
    }

    /**
     * @param array $token - modified in place
     * @return void
     */
    private function clearDeviceCode(array &$token): void {
        unset($token[IMAP_OAUTH2_KEY_DEVICE_CODE], $token[IMAP_OAUTH2_KEY_DEVICE_CODE_EXPIRATION]);
    }

    /**
     * Resolve endpoint/client for the token's registration, applying per-token-file overrides.
     *
     * @param array $token
     * @return array
     * @throws \UserReportException
     */
    public static function getRegistration(array $token): array {

        $name = $token[IMAP_OAUTH2_KEY_REGISTRATION] ?? IMAP_OAUTH2_REGISTRATION_MICROSOFT;

        if (!isset(self::REGISTRATIONS[$name])) {
            throw new \UserReportException("Unknown OAuth2 registration '$name' in token file. "
                . "Known: " . implode(', ', array_keys(self::REGISTRATIONS)), ERROR_IMAP);
        }
        $registration = self::REGISTRATIONS[$name];

        // Optional overrides directly in the token file (e.g. site specific Azure app).
        foreach ([IMAP_OAUTH2_KEY_TOKEN_ENDPOINT, IMAP_OAUTH2_KEY_DEVICECODE_ENDPOINT, IMAP_OAUTH2_KEY_CLIENT_ID,
                     IMAP_OAUTH2_KEY_CLIENT_SECRET, IMAP_OAUTH2_KEY_TENANT, IMAP_OAUTH2_KEY_SCOPE] as $key) {
            if (!empty($token[$key])) {
                $registration[$key] = $token[$key];
            }
        }

        if (empty($registration[IMAP_OAUTH2_KEY_CLIENT_ID])) {
            throw new \UserReportException("No OAuth2 client_id for registration '$name'. "
                . "Add 'client_id' to the token file.", ERROR_IMAP);
        }

        return $registration;
    }

    /**
     * Common POST parameters shared by the device-code, poll and refresh requests.
     *
     * @param array $registration
     * @return array
     */
    private static function buildBaseParams(array $registration): array {

        $params = ['client_id' => $registration[IMAP_OAUTH2_KEY_CLIENT_ID]];
        if (!empty($registration[IMAP_OAUTH2_KEY_TENANT])) {
            $params['tenant'] = $registration[IMAP_OAUTH2_KEY_TENANT];
        }
        if (!empty($registration[IMAP_OAUTH2_KEY_CLIENT_SECRET])) {
            $params['client_secret'] = $registration[IMAP_OAUTH2_KEY_CLIENT_SECRET];
        }

        return $params;
    }

    /**
     * POST parameters for grant_type=refresh_token.
     *
     * @param array $registration
     * @param string $refreshToken
     * @return array
     */
    public static function buildRefreshParams(array $registration, string $refreshToken): array {

        $params = self::buildBaseParams($registration);
        $params['refresh_token'] = $refreshToken;
        $params['grant_type'] = 'refresh_token';

        return $params;
    }

    /**
     * Expiration timestamp for a token valid $expiresIn seconds from now.
     * Format matches Python datetime.isoformat() (mutt oauth2 token format compatibility).
     *
     * @param int $expiresIn
     * @return string
     */
    public static function expirationFromNow(int $expiresIn): string {
        return (new \DateTimeImmutable("+$expiresIn seconds"))->format('Y-m-d\TH:i:s.u');
    }

    /**
     * Fire the refresh POST against the OAuth2 token endpoint and validate the response.
     * Protected to allow mocking in unit tests.
     *
     * @param string $tokenEndpoint
     * @param array $params
     * @param string $pathFileNameAbsolute - just for error messages
     * @return array - decoded response, guaranteed to contain 'access_token' and 'expires_in'
     * @throws \UserReportException
     */
    protected function requestTokenRefresh(string $tokenEndpoint, array $params, string $pathFileNameAbsolute): array {

        $response = $this->postTokenEndpoint($tokenEndpoint, $params);

        if (isset($response['error'])) {
            // Refresh token rejected (expired / killed by a sign-in-frequency policy): re-auth needed.
            // The distinct code ERROR_IMAP_OAUTH_REAUTH lets the daemon notify the admin.
            throw new \UserReportException("OAuth2 token refresh failed ('$pathFileNameAbsolute'): " . $response['error']
                . (isset($response['error_description']) ? ' - ' . $response['error_description'] : '')
                . ". The refresh token is no longer valid - authorize the account again via the emailSync report (key button).", ERROR_IMAP_OAUTH_REAUTH);
        }

        if (empty($response['access_token']) || empty($response['expires_in'])) {
            throw new \UserReportException("OAuth2 token endpoint response misses 'access_token'/'expires_in': $tokenEndpoint", ERROR_IMAP);
        }

        return $response;
    }

    /**
     * True if the exception signals that a delegated account needs interactive re-authorization
     * (refresh token rejected), as opposed to a transient IMAP/network error.
     *
     * @param \Throwable $e
     * @return bool
     */
    public static function isReauthError(\Throwable $e): bool {
        return $e->getCode() === ERROR_IMAP_OAUTH_REAUTH;
    }

    /**
     * Raw POST against an OAuth2 endpoint. OAuth2 error responses (HTTP 4xx with 'error' in the
     * body) are returned to the caller for interpretation - only transport problems throw.
     * Protected to allow mocking in unit tests.
     *
     * @param string $url
     * @param array $params
     * @return array - decoded JSON response
     * @throws \UserReportException
     */
    protected function postTokenEndpoint(string $url, array $params): array {

        $context = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => "Content-Type: application/x-www-form-urlencoded\r\n"
                    . "Accept: application/json\r\n",
                'content' => http_build_query($params),
                'timeout' => 30,
                'ignore_errors' => true, // read the body on HTTP 4xx too: it carries the OAuth2 error details
            ],
        ]);

        $body = @file_get_contents($url, false, $context);
        if (false === $body) {
            $lastError = error_get_last();
            throw new \UserReportException("OAuth2 endpoint not reachable: $url - "
                . ($lastError['message'] ?? 'unknown error'), ERROR_IMAP);
        }

        $response = json_decode($body, true);
        if (!is_array($response)) {
            throw new \UserReportException("OAuth2 endpoint returned invalid JSON: $url", ERROR_IMAP);
        }

        return $response;
    }

    /**
     * Rewrite the (already locked and opened) token file in place. File mode stays untouched (0600).
     *
     * @param resource $fp
     * @param array $token
     * @param string $pathFileNameAbsolute - just for error messages
     * @return void
     * @throws \UserReportException
     */
    private function writeTokenFile($fp, array $token, string $pathFileNameAbsolute): void {

        // Encode BEFORE truncating: on json_encode failure (e.g. invalid UTF-8 in a provider
        // response) fwrite($fp, false) would write '' - the already-truncated file would be left
        // empty and the account would need a full re-authorization.
        $json = json_encode($token);
        if (false === $json) {
            throw new \UserReportException("Can't serialize OAuth2 token file '$pathFileNameAbsolute': "
                . json_last_error_msg(), ERROR_IMAP);
        }

        if (!ftruncate($fp, 0) || !rewind($fp) || false === fwrite($fp, $json)) {
            throw new \UserReportException("Can't update OAuth2 token file: '$pathFileNameAbsolute'", ERROR_IMAP);
        }
        fflush($fp);
    }
}
