<?php
/**
 * Copyright 2016 Google Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

namespace Google\Cloud\Datastore;

use DomainException;
use Google\ApiCore\ClientOptionsTrait;
use Google\ApiCore\CredentialsWrapper;
use Google\ApiCore\Options\ClientOptions;
use Google\Auth\FetchAuthTokenInterface;
use Google\Cloud\Core\ApiHelperTrait;
use Google\Cloud\Core\DetectProjectIdTrait;
use Google\Cloud\Core\EmulatorTrait;
use Google\Cloud\Core\Int64;
use Google\Cloud\Core\TimestampTrait;
use Google\Cloud\Datastore\Query\AggregationQuery;
use Google\Cloud\Datastore\Query\AggregationQueryResult;
use Google\Cloud\Datastore\Query\GqlQuery;
use Google\Cloud\Datastore\Query\Query;
use Google\Cloud\Datastore\Query\QueryInterface;
use Google\Cloud\Datastore\V1\Client\DatastoreClient as GapicDatastoreClient;
use InvalidArgumentException;
use Psr\Http\Message\StreamInterface;

/**
 * Google Cloud Datastore is a highly-scalable NoSQL database for your
 * applications. Find more information at the
 * [Google Cloud Datastore docs](https://cloud.google.com/datastore/docs/).
 *
 * Cloud Datastore supports
 * [multi-tenant](https://cloud.google.com/datastore/docs/concepts/multitenancy)
 * applications through use of data partitions. A partition ID can be supplied
 * when creating an instance of Cloud Datastore, and will be used in all
 * operations executed in that instance.
 *
 * To enable the
 * [Google Cloud Datastore Emulator](https://cloud.google.com/datastore/docs/tools/datastore-emulator),
 * set the [`DATASTORE_EMULATOR_HOST`](https://goo.gl/vCVZrY) environment variable.
 *
 * Example:
 * ```
 * use Google\Cloud\Datastore\DatastoreClient;
 *
 * $datastore = new DatastoreClient();
 * ```
 *
 * ```
 * // Multi-tenant applications can supply a namespace ID.
 * use Google\Cloud\Datastore\DatastoreClient;
 *
 * $datastore = new DatastoreClient([
 *     'namespaceId' => 'my-application-namespace'
 * ]);
 * ```
 *
 * ```
 * // Using the Datastore Emulator
 * use Google\Cloud\Datastore\DatastoreClient;
 *
 * // Be sure to use the port specified when starting the emulator.
 * // `8900` is used as an example only.
 * putenv('DATASTORE_EMULATOR_HOST=localhost:8900');
 *
 * $datastore = new DatastoreClient();
 * ```
 *
 * ```
 * // Multi-database applications can supply a database ID.
 * use Google\Cloud\Datastore\DatastoreClient;
 *
 * $datastore = new DatastoreClient([
 *     'namespaceId' => 'my-application-namespace',
 *     'databaseId' => 'my-database'
 * ]);
 * ```
 */
class DatastoreClient
{
    use DetectProjectIdTrait;
    use DatastoreTrait;
    use TimestampTrait;
    use ApiHelperTrait;
    use ClientOptionsTrait;
    use EmulatorTrait;

    const VERSION = '2.0.0-RC1';

    const FULL_CONTROL_SCOPE = 'https://www.googleapis.com/auth/datastore';

    /**
     * @var Operation
     */
    protected Operation $operation;

    /**
     * @var EntityMapper
     */
    private EntityMapper $entityMapper;

    /**
     * @var GapicDatastoreClient
     */
    private GapicDatastoreClient $gapicClient;

    /**
     * Create a Datastore client.
     *
     * @param array $config [optional] {
     *     Configuration Options.
     *     Some of this options details on {@see ClientOptions}.
     *
     *     @type string $projectId The project ID from the Google Developer's
     *           Console.
     *     @type string $namespaceId Partitions data under a namespace. Useful for
     *           [Multitenant Projects](https://cloud.google.com/datastore/docs/concepts/multitenancy).
     *     @type string $databaseId ID of the database to which the entities belong.
     *     @type bool $returnInt64AsObject If true, 64 bit integers will be
     *           returned as a {@see \Google\Cloud\Core\Int64} object for 32 bit
     *           platform compatibility. **Defaults to** false.
     *     @type GapicDatastoreClient $datastoreClient A client that is of
     *           type {@see GapicDatastoreClient}
     *     @type string $apiEndpoint
     *           The address of the API remote host. May optionally include the port, formatted
     *           as "<uri>:<port>". Default 'datastore.googleapis.com:443'.
     *     @type FetchAuthTokenInterface|CredentialsWrapper $credentials
     *           This option should only be used with a pre-constructed
     *           {@see FetchAuthTokenInterface} or {@see CredentialsWrapper} object. Note that
     *           when one of these objects are provided, any settings in $credentialsConfig will
     *           be ignored.
     *           **Important**: If you are providing a path to a credentials file, or a decoded
     *           credentials file as a PHP array, this usage is now DEPRECATED. Providing an
     *           unvalidated credential configuration to Google APIs can compromise the security
     *           of your systems and data. It is recommended to create the credentials explicitly
     *           ```
     *           use Google\Auth\Credentials\ServiceAccountCredentials;
     *           use Google\Cloud\Datastore\V1\DatastoreClient;
     *           $creds = new ServiceAccountCredentials($scopes, $json);
     *           $options = new DatastoreClient(['credentials' => $creds]);
     *           ```
     *           {@see
     *           https://cloud.google.com/docs/authentication/external/externally-sourced-credentials}
     *     @type array $credentialsConfig
     *           Options used to configure credentials, including auth token caching, for the
     *           client. For a full list of supporting configuration options, see
     *           {@see \Google\ApiCore\CredentialsWrapper::build()} .
     *     @type bool $disableRetries
     *           Determines whether or not retries defined by the client configuration should be
     *           disabled. Defaults to `false`.
     *     @type string|array $clientConfig
     *           Client method configuration, including retry settings. This option can be either
     *           a path to a JSON file, or a PHP array containing the decoded JSON data. By
     *           default this settings points to the default client config file, which is
     *           provided in the resources folder.
     *     @type string|TransportInterface $transport
     *           The transport used for executing network requests. May be either the string
     *           `rest` or `grpc`. Defaults to `grpc` if gRPC support is detected on the system.
     *           *Advanced usage*: Additionally, it is possible to pass in an already
     *           instantiated {@see \Google\ApiCore\Transport\TransportInterface} object. Note
     *           that when this object is provided, any settings in $transportConfig, and any
     *           $apiEndpoint setting, will be ignored.
     *     @type array $transportConfig
     *           Configuration options that will be used to construct the transport. Options for
     *           each supported transport type should be passed in a key for that transport. For
     *           example:
     *           $transportConfig = [
     *               'grpc' => [...],
     *               'rest' => [...],
     *           ];
     *           See the {@see \Google\ApiCore\Transport\GrpcTransport::build()} and
     *           {@see \Google\ApiCore\Transport\RestTransport::build()} methods for the
     *           supported options.
     *     @type callable $clientCertSource
     *           A callable which returns the client cert as a string. This can be used to
     *           provide a certificate and private key to the transport layer for mTLS.
     *     @type false|LoggerInterface $logger
     *           A PSR-3 compliant logger. If set to false, logging is disabled, ignoring the
     *           'GOOGLE_SDK_PHP_LOGGING' environment flag
     *     @type string $universeDomain
     *           The service domain for the client. Defaults to 'googleapis.com'.
     * }
     * @throws \InvalidArgumentException
     */
    public function __construct(array $config = [])
    {
        $emulatorHost = getenv('DATASTORE_EMULATOR_HOST');
        $this->validateConfigurationOptions($config);

        $config += [
            'namespaceId' => null,
            'databaseId' => '',
            'returnInt64AsObject' => false,
            'scopes' => [self::FULL_CONTROL_SCOPE],
            'projectIdRequired' => true,
            'hasEmulator' => (bool) $emulatorHost,
            'emulatorHost' => $emulatorHost,
        ];

        $gapicOptions = $this->buildClientOptions($config);

        if (isset($gapicOptions['credentialsConfig']['scopes'])) {
            $options['credentialsConfig']['scopes'] = array_merge(
                $gapicOptions['credentialsConfig']['scopes'],
                $config['scopes']
            );
        } else {
            $options['credentialsConfig']['scopes'] = $config['scopes'];
        }

        if ($emulatorHost) {
            $emulatorConfig = $this->emulatorGapicConfig($emulatorHost);
            $gapicOptions = array_merge(
                $gapicOptions,
                $emulatorConfig
            );
        } else {
            $gapicOptions['credentials'] = $this->createCredentialsWrapper(
                $gapicOptions['credentials'],
                $gapicOptions['credentialsConfig'],
                $gapicOptions['universeDomain']
            );
        }

        $this->projectId = (string) $this->detectProjectId($gapicOptions);
        $this->gapicClient = $this->getGapicClient($gapicOptions);

        // The second parameter here should change to a variable
        // when gRPC support is added for variable encoding.
        $this->entityMapper = new EntityMapper(
            $this->projectId,
            true,
            $config['returnInt64AsObject']
        );
        $this->operation = new Operation(
            $this->gapicClient,
            $this->projectId,
            $config['namespaceId'],
            $this->entityMapper,
            $config['databaseId']
        );
    }

    /**
     * Create a single Key instance
     *
     * Example:
     * ```
     * $key = $datastore->key('Person', 'Bob');
     * ```
     *
     * ```
     * // To override the internal detection of identifier type, you can specify
     * // which type to use.
     *
     * $key = $datastore->key('Robots', '1337', [
     *     'identifierType' => Key::TYPE_NAME
     * ]);
     * ```
     *
     * @see https://cloud.google.com/datastore/reference/rest/v1/Key Key
     * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement
     *
     * @param string $kind The kind.
     * @param string|int $identifier [optional] The ID or name.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type string $identifierType If omitted, type will be determined
     *           internally. In cases where any ambiguity can be expected (i.e.
     *           if you want to create keys with `name` but your values may
     *           pass PHP's `is_numeric()` check), this value may be
     *           explicitly set using `Key::TYPE_ID` or `Key::TYPE_NAME`.
     * }
     * @return Key
     */
    public function key(string $kind, $identifier = null, array $options = []): Key
    {
        return $this->operation->key($kind, $identifier, $options);
    }

    /**
     * Create multiple keys with the same configuration.
     *
     * When inserting multiple entities, creating a set of keys at once can be
     * useful. By defining the Key's kind and any ancestors up front, and
     * allowing Cloud Datastore to allocate IDs, you can be sure that your
     * entity identity and ancestry are correct and that there will be no
     * collisions during the insert operation.
     *
     * Example:
     * ```
     * $keys = $datastore->keys('Person', [
     *     'number' => 10
     * ]);
     * ```
     *
     * ```
     * // Ancestor paths can be specified
     * $keys = $datastore->keys('Person', [
     *     'ancestors' => [
     *         ['kind' => 'Person', 'name' => 'Grandpa Joe'],
     *         ['kind' => 'Person', 'name' => 'Dad Mike']
     *     ],
     *     'number' => 3
     * ]);
     * ```
     *
     * @see https://cloud.google.com/datastore/reference/rest/v1/Key Key
     * @see https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement PathElement
     *
     * @param string $kind The kind to use in the final path element.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type array[] $ancestors An array of
     *           [PathElement](https://cloud.google.com/datastore/reference/rest/v1/Key#PathElement) arrays. Use to
     *           create [ancestor paths](https://cloud.google.com/datastore/docs/concepts/entities#ancestor_paths).
     *     @type int $number The number of keys to generate.
     *     @type string|int $id The ID for the last pathElement.
     *     @type string $name The Name for the last pathElement.
     * }
     * @return Key[]
     */
    public function keys(string $kind, array $options = []): array
    {
        return $this->operation->keys($kind, $options);
    }

    /**
     * Create an entity.
     *
     * This method does not execute any service requests.
     *
     * Entities are created with a Datastore Key, or by specifying a Kind. Kinds
     * are only allowed for insert operations. For any other case, you must
     * specify a named key. If a kind is given, an ID will be automatically
     * allocated for the entity upon insert. Additionally, if your entity
     * requires a complex key elementPath, you must create the key separately.
     *
     * In complex applications you may want to create your own entity types.
     * Google Cloud PHP supports subclassing of {@see \Google\Cloud\Datastore\Entity}.
     * If the name of a subclass of Entity is given in the options array, an
     * entity will be created with that class rather than the default class.
     *
     * Example:
     * ```
     * $key = $datastore->key('Person', 'Bob');
     * $entity = $datastore->entity($key, [
     *     'firstName' => 'Bob',
     *     'lastName' => 'Testguy'
     * ]);
     * ```
     *
     * ```
     * //[snippet=array]
     * // Entity values can be assigned and accessed via the array syntax.
     * $entity = $datastore->entity($key);
     *
     * $entity['firstName'] = 'Bob';
     * $entity['lastName'] = 'Testguy';
     * ```
     *
     * ```
     * //[snippet=object_accessor]
     * // Entity values can also be assigned and accessed via an object syntax.
     * $entity = $datastore->entity($key);
     *
     * $entity->firstName = 'Bob';
     * $entity->lastName = 'Testguy';
     * ```
     *
     * ```
     * //[snippet=incomplete]
     * // Entities can be created with a Kind only, for inserting into datastore
     * $entity = $datastore->entity('Person');
     * ```
     *
     * ```
     * //[snippet=custom_class_interface]
     * // Entities can be custom classes implementing the Datastore entity interface.
     * use Google\Cloud\Datastore\EntityTrait;
     * use Google\Cloud\Datastore\EntityInterface;
     *
     * class PersonEntity implements EntityInterface
     * {
     *     use EntityTrait;
     * }
     *
     * $person = $datastore->entity('Person', [ 'firstName' => 'Bob'], [
     *     'className' => PersonEntity::class
     * ]);
     *
     * echo get_class($person); // `Person`
     * ```
     *
     * ```
     * //[snippet=custom_class_extends]
     * // Custom entity types may also extend the built-in Entity class.
     * use Google\Cloud\Datastore\Entity;
     *
     * class OtherPersonEntity extends Entity
     * {}
     *
     * $person = $datastore->entity('Person', [ 'firstName' => 'Bob'], [
     *     'className' => OtherPersonEntity::class
     * ]);
     *
     * echo get_class($person); // `Person`
     * ```
     *
     * ```
     * //[snippet=exclude_indexes]
     * // If you wish to exclude certain properties from datastore indexes,
     * // property names may be supplied in the method $options:
     *
     * $entity = $datastore->entity('Person', [
     *     'firstName' => 'Bob',
     *     'dateOfBirth' => new DateTime('January 31, 1969')
     * ], [
     *     'excludeFromIndexes' => [
     *         'dateOfBirth'
     *     ]
     * ]);
     * ```
     *
     * @see https://cloud.google.com/datastore/reference/rest/v1/Entity Entity
     *
     * @param Key|string|null $key [optional] The key used to identify the record, or
     *        a string $kind. The key may be null only if the entity will be
     *        used as an embedded entity within another entity. Attempting to
     *        use keyless entities as root entities will result in error.
     *        **Defaults to** `null`.
     * @param array $entity [optional] The data, provided as an array of keys
     *        and values to fill the entity with. **Defaults to** `[]`.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type string $className If set, the given class will be returned.
     *           Value must be the name of a class implementing
     *           {@see \Google\Cloud\Datastore\EntityInterface}. **Defaults to**
     *           {@see \Google\Cloud\Datastore\Entity}.
     *     @type array $excludeFromIndexes A list of entity keys to exclude from
     *           datastore indexes.
     * }
     * @return EntityInterface
     */
    public function entity(Key|string|null $key = null, array $entity = [], array $options = []): EntityInterface
    {
        return $this->operation->entity($key, $entity, $options);
    }

    /**
     * Create a new GeoPoint
     *
     * Example:
     * ```
     * $geoPoint = $datastore->geoPoint(37.4220, -122.0841);
     * ```
     *
     * @see https://cloud.google.com/datastore/reference/rest/Shared.Types/LatLng LatLng
     *
     * @param float $latitude The latitude
     * @param float $longitude The longitude
     * @param bool $allowNull [optional] Whether null values are allowed.
     *        **Defaults to** `false`.
     * @return GeoPoint
     */
    public function geoPoint(float $latitude, float $longitude, bool $allowNull = false): GeoPoint
    {
        return new GeoPoint($latitude, $longitude, $allowNull);
    }

    /**
     * Create a new Blob
     *
     * Example:
     * ```
     * $blob = $datastore->blob('hello world');
     * ```
     *
     * ```
     * // Blobs can be used to store binary data
     * $blob = $datastore->blob(file_get_contents(__DIR__ .'/family-photo.jpg'));
     * ```
     *
     * @param string|resource|StreamInterface $value The value to store in a blob.
     * @return Blob
     */
    public function blob(mixed $value): Blob
    {
        return new Blob($value);
    }

    /**
     * Create an Int64 object. This can be used to work with 64 bit integers as
     * a string value while on a 32 bit platform.
     *
     * Example:
     * ```
     * $int64 = $datastore->int64('9223372036854775807');
     * ```
     *
     * @param string $value
     * @return Int64
     */
    public function int64(string $value): Int64
    {
        return new Int64($value);
    }

    /**
     * Create a Cursor.
     *
     * A cursor points to a position within a set of entities. Cloud Datastore
     * uses Cursors for paginating query results.
     *
     * Example:
     * ```
     * $cursor = $datastore->cursor($cursorValue);
     * ```
     *
     * @param string|int $cursorValue
     * @return Cursor
     */
    public function cursor(string|int $cursorValue): Cursor
    {
        return new Cursor($cursorValue);
    }

    /**
     * Allocates an available ID to a given incomplete key
     *
     * Key MUST be in an incomplete state (i.e. including a kind but not an ID
     * or name in its final pathElement).
     *
     * This method will execute a service request.
     *
     * Example:
     * ```
     * $key = $datastore->key('Person');
     * $keyWithAllocatedId = $datastore->allocateId($key);
     * ```
     *
     * @see https://cloud.google.com/datastore/reference/rest/v1/projects/allocateIds allocateIds
     *
     * @param Key $key The incomplete key.
     * @param array $options [optional] Configuration options.
     * @return Key
     */
    public function allocateId(Key $key, array $options = []): Key
    {
        $res = $this->allocateIds([$key], $options);
        return $res[0];
    }

    /**
     * Allocate available IDs to a set of keys
     *
     * Keys MUST be in an incomplete state (i.e. including a kind but not an ID
     * or name in their final pathElement).
     *
     * This method will execute a service request.
     *
     * Example:
     * ```
     * $keys = [
     *     $datastore->key('Person'),
     *     $datastore->key('Person')
     * ];
     *
     * $keysWithAllocatedIds = $datastore->allocateIds($keys);
     * ```
     *
     * @see https://cloud.google.com/datastore/reference/rest/v1/projects/allocateIds allocateIds
     *
     * @param Key[] $keys The incomplete keys.
     * @param array $options [optional] {
     *      Configuration Options.
     *
     *      string $databaseId The ID of the database against which to make the request.
     * }.
     * @return Key[]
     */
    public function allocateIds(array $keys, array $options = []): array
    {
        return $this->operation->allocateIds($keys, $options);
    }

    /**
     * Create a Transaction.
     *
     * Example:
     * ```
     * $transaction = $datastore->transaction();
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/concepts/transactions Datastore Transactions
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/beginTransaction beginTransaction
     *
     * @codingStandardsIgnoreStart
     * @param array $options {
     *     Configuration options.
     *     @type array $transactionOptions Transaction configuration. See
     *           [ReadWrite](https://cloud.google.com/datastore/docs/reference/rest/v1/projects/beginTransaction#ReadWrite).
     *     @type string $databaseId ID of the database to which the entities belong.
     * }
     * @return Transaction
     * @codingStandardsIgnoreEnd
     */
    public function transaction(array $options = []): Transaction
    {
        $transaction = $this->operation->beginTransaction([
            // if empty, force request to encode as {} rather than [].
            'readWrite' => $this->pluck('transactionOptions', $options, false) ?: []
        ], $options);

        return new Transaction(
            $this->operation,
            $this->projectId,
            $transaction
        );
    }
    /**
     * Create a Read-Only Transaction.
     *
     * Example:
     * ```
     * $transaction = $datastore->readOnlyTransaction();
     * ```
     * Example with readTime option:
     * ```
     * $transaction = $datastore->readOnlyTransaction(['transactionOptions' => ['readTime' => $time]]);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/concepts/transactions Datastore Transactions
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/beginTransaction beginTransaction
     *
     * @codingStandardsIgnoreStart
     * @param array $options {
     *     Configuration options.
     *
     *     @type array $transactionOptions See
     *           [ReadOnly](https://cloud.google.com/datastore/docs/reference/rest/v1/projects/beginTransaction#ReadOnly).
     *     @type string $databaseId ID of the database to which the entities belong.
     * }
     * @return ReadOnlyTransaction
     * @codingStandardsIgnoreEnd
     */
    public function readOnlyTransaction(array $options = []): ReadOnlyTransaction
    {
        $transaction = $this->operation->beginTransaction([
            // if empty, force request to encode as {} rather than [].
            'readOnly' => $this->pluck('transactionOptions', $options, false) ?: []
        ], $options);

        return new ReadOnlyTransaction(
            $this->operation,
            $this->projectId,
            $transaction
        );
    }

    /**
     * Insert an entity
     *
     * An entity with incomplete keys will be allocated an ID prior to insertion.
     *
     * Insert by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::insert()}.
     *
     * Example:
     * ```
     * $key = $datastore->key('Person', 'Bob');
     * $entity = $datastore->entity($key, ['firstName' => 'Bob']);
     *
     * $datastore->insert($entity);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param EntityInterface $entity The entity to be inserted.
     * @param array $options [optional] Configuration options.
     * @return string The entity version.
     * @throws DomainException If a conflict occurs, fail.
     */
    public function insert(EntityInterface $entity, array $options = []): string
    {
        $res = $this->insertBatch([$entity], $options);
        return $this->parseSingleMutationResult($res);
    }

    /**
     * Insert multiple entities
     *
     * Any entity with incomplete keys will be allocated an ID prior to insertion.
     *
     * Insert by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::insertBatch()}.
     *
     * Example:
     * ```
     *
     * $entities = [
     *     $datastore->entity('Person', ['firstName' => 'Bob']),
     *     $datastore->entity('Person', ['firstName' => 'John'])
     * ];
     *
     * $datastore->insertBatch($entities);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param EntityInterface[] $entities The entities to be inserted.
     * @param array $options [optional] Configuration options.
     * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body)
     */
    public function insertBatch(array $entities, array $options = []): array
    {
        $entities = $this->operation->allocateIdsToEntities($entities);
        $mutations = [];
        foreach ($entities as $entity) {
            $mutations[] = $this->operation->mutation('insert', $entity, Entity::class);
        }

        return $this->operation->commit($mutations, $options);
    }

    /**
     * Update an entity
     *
     * Please note that updating a record in Cloud Datastore will replace the
     * existing record. Adding, editing or removing a single property is only
     * possible by first retrieving the entire entity in its existing state.
     *
     * Update by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::update()}.
     *
     * Example:
     * ```
     * $entity['firstName'] = 'John';
     *
     * $datastore->update($entity);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param EntityInterface $entity The entity to be updated.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type bool $allowOverwrite Entities must be updated as an entire
     *           resource. Patch operations are not supported. Because entities
     *           can be created manually, or obtained by a lookup or query, it
     *           is possible to accidentally overwrite an existing record with a
     *           new one when manually creating an entity. To provide additional
     *           safety, this flag must be set to `true` in order to update a
     *           record when the entity provided was not obtained through a
     *           lookup or query. **Defaults to** `false`.
     * }
     * @return string The entity version.
     * @throws DomainException If a conflict occurs, fail.
     */
    public function update(EntityInterface $entity, array $options = []): string
    {
        $res = $this->updateBatch([$entity], $options);
        return $this->parseSingleMutationResult($res);
    }

    /**
     * Update multiple entities
     *
     * Please note that updating a record in Cloud Datastore will replace the
     * existing record. Adding, editing or removing a single property is only
     * possible by first retrieving the entire entity in its existing state.
     *
     * Update by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::updateBatch()}.
     *
     * Example:
     * ```
     * $entities[0]['firstName'] = 'Bob';
     * $entities[1]['firstName'] = 'John';
     *
     * $datastore->updateBatch($entities);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param EntityInterface[] $entities The entities to be updated.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type bool $allowOverwrite Entities must be updated as an entire
     *           resource. Patch operations are not supported. Because entities
     *           can be created manually, or obtained by a lookup or query, it
     *           is possible to accidentally overwrite an existing record with a
     *           new one when manually creating an entity. To provide additional
     *           safety, this flag must be set to `true` in order to update a
     *           record when the entity provided was not obtained through a
     *           lookup or query. **Defaults to** `false`.
     * }
     * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body)
     */
    public function updateBatch(array $entities, array $options = []): array
    {
        $options += [
            'allowOverwrite' => false
        ];

        $this->operation->checkOverwrite($entities, $this->pluck('allowOverwrite', $options));
        $mutations = [];
        foreach ($entities as $entity) {
            $mutations[] = $this->operation->mutation('update', $entity, Entity::class);
        }

        return $this->operation->commit($mutations, $options);
    }

    /**
     * Upsert an entity
     *
     * Upsert will create a record if one does not already exist, or overwrite
     * existing record if one already exists.
     *
     * Please note that upserting a record in Cloud Datastore will replace the
     * existing record, if one exists. Adding, editing or removing a single
     * property is only possible by first retrieving the entire entity in its
     * existing state.
     *
     * An entity with incomplete keys will be allocated an ID prior to insertion.
     *
     * Upsert by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::upsert()}.
     *
     * Example:
     * ```
     * $key = $datastore->key('Person', 'Bob');
     * $entity = $datastore->entity($key, ['firstName' => 'Bob']);
     *
     * $datastore->upsert($entity);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param EntityInterface $entity The entity to be upserted.
     * @param array $options [optional] Configuration Options.
     * @return string The entity version.
     * @throws DomainException If a conflict occurs, fail.
     */
    public function upsert(EntityInterface $entity, array $options = []): string
    {
        $res = $this->upsertBatch([$entity], $options);
        return $this->parseSingleMutationResult($res);
    }

    /**
     * Upsert multiple entities
     *
     * Upsert will create a record if one does not already exist, or overwrite
     * an existing record if one already exists.
     *
     * Please note that upserting a record in Cloud Datastore will replace the
     * existing record, if one exists. Adding, editing or removing a single
     * property is only possible by first retrieving the entire entity in its
     * existing state.
     *
     * Any entity with incomplete keys will be allocated an ID prior to insertion.
     *
     * Upsert by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::upsertBatch()}.
     *
     * Example:
     * ```
     * $keys = [
     *     $datastore->key('Person', 'Bob'),
     *     $datastore->key('Person', 'John')
     * ];
     *
     * $entities = [
     *     $datastore->entity($keys[0], ['firstName' => 'Bob']),
     *     $datastore->entity($keys[1], ['firstName' => 'John'])
     * ];
     *
     * $datastore->upsertBatch($entities);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param EntityInterface[] $entities The entities to be upserted.
     * @param array $options [optional] Configuration Options.
     * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body)
     */
    public function upsertBatch(array $entities, array $options = []): array
    {
        $entities = $this->operation->allocateIdsToEntities($entities);
        $mutations = [];
        foreach ($entities as $entity) {
            $mutations[] = $this->operation->mutation('upsert', $entity, Entity::class);
        }

        return $this->operation->commit($mutations, $options);
    }

    /**
     * Delete an entity
     *
     * Deletion by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::delete()}.
     *
     * Example:
     * ```
     * $key = $datastore->key('Person', 'Bob');
     *
     * $datastore->delete($key);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param Key $key The identifier to delete.
     * @param array $options [optional] {
     *     Configuration options
     *
     *     @type string $baseVersion Provides concurrency control. The version
     *           of the entity that this mutation is being applied to. If this
     *           does not match the current version on the server, the mutation
     *           conflicts.
     * }
     * @return string The updated entity version number.
     * @throws DomainException If a conflict occurs, fail.
     */
    public function delete(Key $key, array $options = []): string
    {
        $res = $this->deleteBatch([$key], $options);
        return $this->parseSingleMutationResult($res);
    }

    /**
     * Delete multiple entities
     *
     * Deletion by this method is non-transactional. If you need transaction
     * support, use {@see \Google\Cloud\Datastore\Transaction::deleteBatch()}.
     *
     * Example:
     * ```
     * $keys = [
     *     $datastore->key('Person', 'Bob'),
     *     $datastore->key('Person', 'John')
     * ];
     *
     * $datastore->deleteBatch($keys);
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/commit Commit API documentation
     *
     * @param Key[] $keys The identifiers to delete.
     * @param array $options [optional] {
     *     Configuration options
     *
     *     @type string $baseVersion Provides concurrency control. The version
     *           of the entity that this mutation is being applied to. If this
     *           does not match the current version on the server, the mutation
     *           conflicts.
     * }
     * @return array [Response Body](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#response-body)
     */
    public function deleteBatch(array $keys, array $options = []): array
    {
        $options += [
            'baseVersion' => null
        ];

        $mutations = [];
        foreach ($keys as $key) {
            $mutations[] = $this->operation
                ->mutation('delete', $key, Key::class, $this->pluck('baseVersion', $options, false));
        }

        return $this->operation->commit($mutations, $options);
    }

    /**
     * Retrieve an entity from the datastore
     *
     * To lookup an entity inside a transaction, use
     * {@see \Google\Cloud\Datastore\Transaction::lookup()}.
     *
     * Example:
     * ```
     * $key = $datastore->key('Person', 'Bob');
     *
     * $entity = $datastore->lookup($key);
     * if (!is_null($entity)) {
     *     echo $entity['firstName']; // 'Bob'
     * }
     * ```
     * Example with readTime:
     * ```
     * $key = $datastore->key('Person', 'Bob');
     *
     * $entity = $datastore->lookup($key, ['readTime' => $time]);
     * if (!is_null($entity)) {
     *     echo $entity['firstName']; // 'Bob'
     * }
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/lookup Lookup API documentation
     *
     * @param Key $key The identifier to use to locate a desired entity.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type string $readConsistency See
     *           [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency).
     *     @type string $className If set, the given class will be returned.
     *           Value must be the name of a class implementing
     *           {@see \Google\Cloud\Datastore\EntityInterface}. **Defaults to**
     *           {@see \Google\Cloud\Datastore\Entity}.
     *     @type string $databaseId ID of the database to which the entities belong.
     *     @type Timestamp $readTime Reads entities as they were at the given timestamp.
     * }
     * @return EntityInterface|null
     */
    public function lookup(Key $key, array $options = []): ?EntityInterface
    {
        $res = $this->lookupBatch([$key], $options);

        return (isset($res['found'][0]))
            ? $res['found'][0]
            : null;
    }

    /**
     * Get multiple entities
     *
     * To lookup entities inside a transaction, use
     * {@see \Google\Cloud\Datastore\Transaction::lookupBatch()}.
     *
     * Example:
     * ```
     * $keys = [
     *     $datastore->key('Person', 'Bob'),
     *     $datastore->key('Person', 'John')
     * ];
     *
     * $entities = $datastore->lookupBatch($keys);
     *
     * foreach ($entities['found'] as $entity) {
     *     echo $entity['firstName'] . PHP_EOL;
     * }
     * ```
     * Example with readTime:
     * ```
     * $keys = [
     *     $datastore->key('Person', 'Bob'),
     *     $datastore->key('Person', 'John')
     * ];
     *
     * $entities = $datastore->lookupBatch($keys, ['readTime' => $time]);
     *
     * foreach ($entities['found'] as $entity) {
     *     echo $entity['firstName'] . PHP_EOL;
     * }
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/lookup Lookup API documentation
     *
     * @param Key[] $keys The identifiers to look up.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type string $readConsistency See
     *           [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency).
     *     @type string|array $className If a string, the given class will be
     *           returned. Value must be the name of a class implementing
     *           {@see \Google\Cloud\Datastore\EntityInterface}.
     *           If an array is given, it must be an associative array, where
     *           the key is a Kind and the value must implement
     *           {@see \Google\Cloud\Datastore\EntityInterface}. **Defaults to**
     *           {@see \Google\Cloud\Datastore\Entity}.
     *     @type bool $sort If set to true, results in each set will be sorted
     *           to match the order given in $keys. **Defaults to** `false`.
     *     @type string $databaseId ID of the database to which the entities belong.
     *     @type Timestamp $readTime Reads entities as they were at the given timestamp.
     * }
     * @return array Returns an array with keys [`found`, `missing`, and `deferred`].
     *         Members of `found` will be instance of
     *         {@see \Google\Cloud\Datastore\Entity}. Members of `missing` and
     *         `deferred` will be instance of {@see \Google\Cloud\Datastore\Key}.
     */
    public function lookupBatch(array $keys, array $options = []): array
    {
        return $this->operation->lookup($keys, $options);
    }

    /**
     * Create a Query object.
     *
     * The Query class can be used as a builder, or it can accept a query
     * representation at instantiation.
     *
     * Example:
     * ```
     * $query = $datastore->query();
     * ```
     *
     * @param array $query [Query](https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#query)
     * @return Query
     */
    public function query(array $query = []): Query
    {
        return new Query($this->entityMapper, [
            'query' => $query
        ]);
    }

    /**
     * Create an AggregationQuery object.
     *
     * In addition to Query features, it supports aggregations.
     *
     * Example:
     * ```
     * $query = $datastore->aggregationQuery();
     * ```
     *
     * @param array $query [Query](https://cloud.google.com/datastore/reference/rest/v1/projects/runQuery#query)
     * @return AggregationQuery
     */
    public function aggregationQuery(array $query = []): AggregationQuery
    {
        return new AggregationQuery($this->query($query));
    }

    /**
     * Create a GqlQuery object.
     *
     * Returns a Query object which can be executed using
     * {@see \Google\Cloud\Datastore\DatastoreClient::runQuery()}.
     *
     * Example:
     * ```
     * $query = $datastore->gqlQuery('SELECT * FROM Companies');
     * ```
     *
     * ```
     * //[snippet=bindings]
     * // Literals must be provided as bound parameters by default:
     * $query = $datastore->gqlQuery('SELECT * FROM Companies WHERE companyName = @companyName', [
     *     'bindings' => [
     *         'companyName' => 'Google'
     *     ]
     * ]);
     * ```
     *
     * ```
     * //[snippet=pos_bindings]
     * // Positional binding is also supported:
     * $query = $datastore->gqlQuery('SELECT * FROM Companies WHERE companyName = @1 LIMIT 1', [
     *     'bindings' => [
     *         'Google'
     *     ]
     * ]);
     * ```
     *
     * ```
     * //[snippet=literals]
     * // While not recommended, you can use literals in your query string:
     * $query = $datastore->gqlQuery('SELECT * FROM Companies WHERE companyName = \'Google\'', [
     *     'allowLiterals' => true
     * ]);
     * ```
     *
     * ```
     * //[snippet=cursor]
     * // Using cursors as query bindings:
     * $cursor = $datastore->cursor($cursorValue);
     *
     * $query = $datastore->gqlQuery('SELECT * FROM Companies OFFSET @offset', [
     *     'bindings' => [
     *         'offset' => $cursor
     *     ]
     * ]);
     * ```
     *
     * @param string $query The [GQL Query](https://cloud.google.com/datastore/docs/apis/gql/gql_reference) string.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type bool $allowLiterals Whether literal values will be allowed in
     *           the query string. Parameter binding is strongly encouraged over
     *           literals. **Defaults to** `false`.
     *     @type array $bindings An array of values to bind to the query string.
     *           Queries using Named Bindings should provide a key/value set,
     *           while queries using Positional Bindings must provide a simple
     *           array. Query cursors may be provided using instances of
     *           {@see \Google\Cloud\Datastore\Cursor}.
     *     @type string $readConsistency See
     *           [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency).
     * }
     * @return GqlQuery
     */
    public function gqlQuery(string $query, array $options = []): GqlQuery
    {
        return new GqlQuery($this->entityMapper, $query, $options);
    }

    /**
     * Run a query and return entities
     *
     * To query datastore inside a transaction, use
     * {@see \Google\Cloud\Datastore\Transaction::runQuery()}.
     *
     * Example:
     * ```
     * $result = $datastore->runQuery($query);
     *
     * foreach ($result as $entity) {
     *     echo $entity['firstName'];
     * }
     * ```
     * Example with readTime:
     * ```
     * $result = $datastore->runQuery($query, ['readTime' => $time]);
     *
     * foreach ($result as $entity) {
     *     echo $entity['firstName'];
     * }
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/runQuery RunQuery API documentation
     *
     * @param QueryInterface $query A query object.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type string $className If set, the given class will be returned.
     *           Value must be the name of a class implementing
     *           {@see \Google\Cloud\Datastore\EntityInterface}. **Defaults to**
     *           {@see \Google\Cloud\Datastore\Entity}.
     *     @type string $readConsistency See
     *           [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency).
     *     @type Timestamp $readTime Reads entities as they were at the given timestamp.
     * }
     * @return EntityIterator<EntityInterface>
     */
    public function runQuery(QueryInterface $query, array $options = [])
    {
        return $this->operation->runQuery($query, $options);
    }

    /**
     * Run an aggregation query and return results.
     *
     * To query datastore inside a transaction, use
     * {@see \Google\Cloud\Datastore\Transaction::runAggregationQuery()}.
     *
     * Example:
     * ```
     * $results = $datastore->runAggregationQuery($query);
     * echo $results->get('property_1');
     * ```
     *
     * Example with readTime:
     * ```
     * $results = $datastore->runAggregationQuery($query, ['readTime' => $time]);
     * echo $results->get('property_1');
     * ```
     *
     * @see https://cloud.google.com/datastore/docs/reference/rest/v1/projects/runAggregationQuery
     * RunAggregationQuery API documentation
     *
     * @param AggregationQuery $query A query object.
     * @param array $options [optional] {
     *     Configuration Options
     *
     *     @type string $readConsistency See
     *           [ReadConsistency](https://cloud.google.com/datastore/reference/rest/v1/ReadOptions#ReadConsistency).
     *     @type Timestamp $readTime Reads entities as they were at the given timestamp.
     *     @type ExplainOptions $explainOptions An ExplainOptions instance.
     *           {@see \Google\Cloud\Datastore\V1\ExplainOptions}
     * }
     * @return AggregationQueryResult
     */
    public function runAggregationQuery(AggregationQuery $query, array $options = []): AggregationQueryResult
    {
        return $this->operation->runAggregationQuery($query, $options);
    }

    /**
     * Handle mutation results
     *
     * @codingStandardsIgnoreStart
     * @param array $res [MutationResult](https://cloud.google.com/datastore/reference/rest/v1/projects/commit#MutationResult)
     * @return string
     * @throws DomainException
     * @codingStandardsIgnoreEnd
     */
    private function parseSingleMutationResult(array $res): string
    {
        $mutationResult = $res['mutationResults'][0];

        if (isset($mutationResult['conflictDetected']) && $mutationResult['conflictDetected']) {
            throw new DomainException(
                'A conflict was detected in the mutation. ' .
                'The operation failed.'
            );
        }

        // cast to string for conformance between REST and gRPC.
        return (string) $mutationResult['version'];
    }

    private function getGapicClient(array $config): GapicDatastoreClient
    {
        if (isset($config['datastoreClient']) && (!$config['datastoreClient'] instanceof GapicDatastoreClient)) {
            throw new InvalidArgumentException(
                'The client configuration option must be an instance of ' . GapicDatastoreClient::class
            );
        }

        return $config['datastoreClient'] ?? new GapicDatastoreClient($config);
    }

    private function validateConfigurationOptions(array $config): void
    {
        $availableOptions = [
            'projectId',
            'namespaceId',
            'databaseId',
            'returnInt64AsObject',
            'datastoreClient',
            'apiEndpoint',
            'credentials',
            'credentialsConfig',
            'disableRetries',
            'clientConfig',
            'transport',
            'transportConfig',
            'clientCertSource',
            'logger',
            'universeDomain'
        ];

        $this->validateOptions($config, $availableOptions);
    }
}
