<?php
/*
 * Copyright 2024 Google LLC
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

namespace Google\ApiCore;

use Google\ApiCore\Options\ClientOptions;
use Google\Auth\ApplicationDefaultCredentials;
use Google\Auth\CredentialsLoader;
use Google\Auth\FetchAuthTokenInterface;
use Google\Auth\GetUniverseDomainInterface;
use Google\Auth\HttpHandler\HttpHandlerFactory;
use Grpc\Gcp\ApiConfig;
use Grpc\Gcp\Config;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;

/**
 * Common functions used to work with various clients.
 *
 * @internal
 */
trait ClientOptionsTrait
{
    use ArrayTrait;

    private static $gapicVersionFromFile;

    private static function getGapicVersion(array $options)
    {
        if (isset($options['libVersion'])) {
            return $options['libVersion'];
        }
        if (!isset(self::$gapicVersionFromFile)) {
            self::$gapicVersionFromFile = AgentHeader::readGapicVersionFromFile(__CLASS__);
        }
        return self::$gapicVersionFromFile;
    }

    private static function initGrpcGcpConfig(string $hostName, string $confPath)
    {
        $apiConfig = new ApiConfig();
        $apiConfig->mergeFromJsonString(file_get_contents($confPath));
        $config = new Config($hostName, $apiConfig);
        return $config;
    }

    /**
     * Get default options. This function should be "overridden" by clients using late static
     * binding to provide default options to the client.
     *
     * @return array
     * @access private
     */
    private static function getClientDefaults()
    {
        return [];
    }

    /**
     * Resolve client options based on the client's default
     * ({@see ClientOptionsTrait::getClientDefault}) and the default for all
     * Google APIs.
     *
     * 1. Set default client option values
     * 2. Set default logger (and log user-supplied configuration options)
     * 3. Set default transport configuration
     * 4. Call "modifyClientOptions" (for backwards compatibility)
     * 5. Use "defaultScopes" when custom endpoint is supplied
     * 6. Load mTLS from the environment if configured
     * 7. Resolve endpoint based on universe domain template when possible
     * 8. Load sysvshm grpc config when possible
     */
    private function buildClientOptions(array|ClientOptions $options)
    {
        if ($options instanceof ClientOptions) {
            $options = $options->toArray();
        }

        // Build $defaultOptions starting from top level
        // variables, then going into deeper nesting, so that
        // we will not encounter missing keys
        $defaultOptions = self::getClientDefaults();
        $defaultOptions += [
            'disableRetries' => false,
            'credentials' => null,
            'credentialsConfig' => [],
            'transport' => null,
            'transportConfig' => [],
            'gapicVersion' => self::getGapicVersion($options),
            'libName' => null,
            'libVersion' => null,
            'apiEndpoint' => null,
            'clientCertSource' => null,
            'universeDomain' => null,
            'logger' => null,
        ];

        $supportedTransports = $this->supportedTransports();
        foreach ($supportedTransports as $transportName) {
            if (!array_key_exists($transportName, $defaultOptions['transportConfig'])) {
                $defaultOptions['transportConfig'][$transportName] = [];
            }
        }
        if (in_array('grpc', $supportedTransports)) {
            $defaultOptions['transportConfig']['grpc'] = [
                'stubOpts' => ['grpc.service_config_disable_resolution' => 1]
            ];
        }

        // Keep track of the API Endpoint
        $apiEndpoint = $options['apiEndpoint'] ?? null;

        // Keep track of the original user supplied options for logging the configuration
        $clientSuppliedOptions = $options;

        // Merge defaults into $options starting from top level
        // variables, then going into deeper nesting, so that
        // we will not encounter missing keys
        $options += $defaultOptions;

        // If logger is explicitly set to false, logging is disabled
        if (is_null($options['logger'])) {
            $options['logger'] = ApplicationDefaultCredentials::getDefaultLogger();
        }

        if (
            $options['logger'] !== null
            && $options['logger'] !== false
            && !$options['logger'] instanceof LoggerInterface
        ) {
            throw new ValidationException(
                'The "logger" option in the options array should be PSR-3 LoggerInterface compatible'
            );
        }

        // Log the user supplied configuration.
        $this->logConfiguration($options['logger'], $clientSuppliedOptions);

        if (isset($options['logger'])) {
            $options['credentialsConfig']['authHttpHandler'] = HttpHandlerFactory::build(
                logger: $options['logger']
            );
        }

        $options['credentialsConfig'] += $defaultOptions['credentialsConfig'];
        $options['transportConfig'] += $defaultOptions['transportConfig'];  // @phpstan-ignore-line
        if (isset($options['transportConfig']['grpc'])) {
            $options['transportConfig']['grpc'] += $defaultOptions['transportConfig']['grpc'];
            $options['transportConfig']['grpc']['stubOpts'] += $defaultOptions['transportConfig']['grpc']['stubOpts'];
            $options['transportConfig']['grpc']['logger'] = $options['logger'] ?? null;
        }
        if (isset($options['transportConfig']['rest'])) {
            $options['transportConfig']['rest'] += $defaultOptions['transportConfig']['rest'];
            $options['transportConfig']['rest']['logger'] = $options['logger'] ?? null;
        }
        if (isset($options['transportConfig']['grpc-fallback'])) {
            $options['transportConfig']['grpc-fallback']['logger'] = $options['logger'] ?? null;
        }

        // These calls do not apply to "New Surface" clients.
        if ($this->isBackwardsCompatibilityMode()) {
            $preModifiedOptions = $options;
            $this->modifyClientOptions($options);
            // NOTE: this is required to ensure backwards compatiblity with $options['apiEndpoint']
            if ($options['apiEndpoint'] !== $preModifiedOptions['apiEndpoint']) {
                $apiEndpoint = $options['apiEndpoint'];
            }

            // serviceAddress is now deprecated and acts as an alias for apiEndpoint
            if (isset($options['serviceAddress'])) {
                $apiEndpoint = $this->pluck('serviceAddress', $options, false);
            }
        } else {
            // Ads is using this method in their new surface clients, so we need to call it.
            // However, this method is not used anywhere else for the new surface clients
            // @TODO: Remove this in GAX V2
            $this->modifyClientOptions($options);
        }
        // If an API endpoint is different form the default, ensure the "audience" does not conflict
        // with the custom endpoint by setting "user defined" scopes.
        if ($apiEndpoint
            && $apiEndpoint != $defaultOptions['apiEndpoint']
            && empty($options['credentialsConfig']['scopes'])
            && !empty($options['credentialsConfig']['defaultScopes'])
        ) {
            $options['credentialsConfig']['scopes'] = $options['credentialsConfig']['defaultScopes'];
        }

        // mTLS: detect and load the default clientCertSource if the environment variable
        // "GOOGLE_API_USE_CLIENT_CERTIFICATE" is true, and the cert source is available
        if (empty($options['clientCertSource']) && CredentialsLoader::shouldLoadClientCertSource()) {
            if ($defaultCertSource = CredentialsLoader::getDefaultClientCertSource()) {
                $options['clientCertSource'] = function () use ($defaultCertSource) {
                    $cert = call_user_func($defaultCertSource);

                    // the key and the cert are returned in one string
                    return [$cert, $cert];
                };
            }
        }

        // mTLS: If no apiEndpoint has been supplied by the user, and either
        // GOOGLE_API_USE_MTLS_ENDPOINT tells us to, or mTLS is available, use the mTLS endpoint.
        if (is_null($apiEndpoint) && $this->shouldUseMtlsEndpoint($options)) {
            $apiEndpoint = self::determineMtlsEndpoint($options['apiEndpoint']);
        }

        // If the user has not supplied a universe domain, use the environment variable if set.
        // Otherwise, use the default ("googleapis.com").
        $options['universeDomain'] ??= getenv('GOOGLE_CLOUD_UNIVERSE_DOMAIN')
            ?: GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN;

        // mTLS: It is not valid to configure mTLS outside of "googleapis.com" (yet)
        if (isset($options['clientCertSource'])
            && $options['universeDomain'] !== GetUniverseDomainInterface::DEFAULT_UNIVERSE_DOMAIN
        ) {
            throw new ValidationException(
                'mTLS is not supported outside the "googleapis.com" universe'
            );
        }

        if (is_null($apiEndpoint)) {
            if (defined('self::SERVICE_ADDRESS_TEMPLATE')) {
                // Derive the endpoint from the service address template and the universe domain
                $apiEndpoint = str_replace(
                    'UNIVERSE_DOMAIN',
                    $options['universeDomain'],
                    self::SERVICE_ADDRESS_TEMPLATE
                );
            } else {
                // For older clients, the service address template does not exist. Use the default
                // endpoint instead.
                $apiEndpoint = $defaultOptions['apiEndpoint'];
            }
        }

        if (extension_loaded('sysvshm')
            && isset($options['gcpApiConfigPath'])
            && file_exists($options['gcpApiConfigPath'])
            && !empty($apiEndpoint)
        ) {
            $grpcGcpConfig = self::initGrpcGcpConfig(
                $apiEndpoint,
                $options['gcpApiConfigPath']
            );

            if (!array_key_exists('stubOpts', $options['transportConfig']['grpc'])) {
                $options['transportConfig']['grpc']['stubOpts'] = [];
            }

            $options['transportConfig']['grpc']['stubOpts'] += [
                'grpc_call_invoker' => $grpcGcpConfig->callInvoker()
            ];
        }

        $options['apiEndpoint'] = $apiEndpoint;

        return $options;
    }

    private function shouldUseMtlsEndpoint(array $options)
    {
        $mtlsEndpointEnvVar = getenv('GOOGLE_API_USE_MTLS_ENDPOINT');
        if ('always' === $mtlsEndpointEnvVar) {
            return true;
        }
        if ('never' === $mtlsEndpointEnvVar) {
            return false;
        }
        // For all other cases, assume "auto" and return true if clientCertSource exists
        return !empty($options['clientCertSource']);
    }

    private static function determineMtlsEndpoint(string $apiEndpoint)
    {
        $parts = explode('.', $apiEndpoint);
        if (count($parts) < 3) {
            return $apiEndpoint; // invalid endpoint!
        }
        return sprintf('%s.mtls.%s', array_shift($parts), implode('.', $parts));
    }

    /**
     * @param mixed $credentials
     * @param array $credentialsConfig
     * @return CredentialsWrapper
     * @throws ValidationException
     */
    private function createCredentialsWrapper($credentials, array $credentialsConfig, string $universeDomain)
    {
        if (is_null($credentials)) {
            // If the user has explicitly set the apiKey option, use Api Key credentials
            return CredentialsWrapper::build($credentialsConfig, $universeDomain);
        }

        if (is_string($credentials) || is_array($credentials)) {
            return CredentialsWrapper::build(['keyFile' => $credentials] + $credentialsConfig, $universeDomain);
        }

        if ($credentials instanceof FetchAuthTokenInterface) {
            $authHttpHandler = $credentialsConfig['authHttpHandler'] ?? null;
            return new CredentialsWrapper($credentials, $authHttpHandler, $universeDomain);
        }

        if ($credentials instanceof CredentialsWrapper) {
            return $credentials;
        }

        throw new ValidationException(sprintf(
            'Unexpected value in $auth option, got: %s',
            print_r($credentials, true)
        ));
    }

    /**
     * This defaults to all three transports, which One-Platform supports.
     * Discovery clients should define this function and only return ['rest'].
     */
    private static function supportedTransports()
    {
        return ['grpc', 'grpc-fallback', 'rest'];
    }

    // Gapic Client Extension Points
    // The methods below provide extension points that can be used to customize client
    // functionality. These extension points are currently considered
    // private and may change at any time.

    /**
     * Modify options passed to the client before calling setClientOptions.
     *
     * @param array $options
     * @access private
     * @internal
     */
    protected function modifyClientOptions(array &$options)
    {
        // Do nothing - this method exists to allow option modification by partial veneers.
    }

    /**
     * @internal
     */
    private function isBackwardsCompatibilityMode(): bool
    {
        return false;
    }

    /**
     * @param null|false|LoggerInterface $logger
     * @param string $options
     */
    private function logConfiguration(null|false|LoggerInterface $logger, array $options): void
    {
        if (!$logger) {
            return;
        }

        $configurationLog = [
            'timestamp' => date(DATE_RFC3339),
            'severity' => strtoupper(LogLevel::DEBUG),
            'processId' => getmypid(),
            'jsonPayload' => [
                'serviceName' => self::SERVICE_NAME, // @phpstan-ignore-line
                'clientConfiguration' => $options,
            ]
        ];

        $logger->debug(json_encode($configurationLog));
    }
}
