<?php

namespace Elastica;

use Elastica\Exception\InvalidException;
use Elastica\Exception\NotFoundException;
use Elastica\Exception\RuntimeException;
use Elastica\ResultSet\BuilderInterface;
use Elastica\Script\AbstractScript;
use Elastica\Type\Mapping;
use Elasticsearch\Endpoints\AbstractEndpoint;
use Elasticsearch\Endpoints\Delete;
use Elasticsearch\Endpoints\DeleteByQuery;
use Elasticsearch\Endpoints\Indices\Mapping\Get;
use Elasticsearch\Endpoints\Indices\Type\Exists;

/**
 * Elastica type object.
 *
 * elasticsearch has for every types as a substructure. This object
 * represents a type inside a context
 * The hierarchy is as following: client -> index -> type -> document
 *
 * @author   Nicolas Ruflin <spam@ruflin.com>
 */
class Type implements SearchableInterface
{
    /**
     * Index.
     *
     * @var \Elastica\Index Index object
     */
    protected $_index;

    /**
     * Type name.
     *
     * @var string Type name
     */
    protected $_name;

    /**
     * @var array|string A callable that serializes an object passed to it
     */
    protected $_serializer;

    /**
     * Creates a new type object inside the given index.
     *
     * @param \Elastica\Index $index Index Object
     * @param string          $name  Type name
     */
    public function __construct(Index $index, $name)
    {
        $this->_index = $index;
        $this->_name = $name;
    }

    /**
     * Adds the given document to the search index.
     *
     * @param \Elastica\Document $doc Document with data
     *
     * @return \Elastica\Response
     */
    public function addDocument(Document $doc)
    {
        $endpoint = new \Elasticsearch\Endpoints\Index();

        if (null !== $doc->getId() && '' !== $doc->getId()) {
            $endpoint->setID($doc->getId());
        }

        $options = $doc->getOptions(
            [
                'version',
                'version_type',
                'routing',
                'percolate',
                'parent',
                'op_type',
                'consistency',
                'replication',
                'refresh',
                'timeout',
                'pipeline',
            ]
        );

        $endpoint->setBody($doc->getData());
        $endpoint->setParams($options);

        $response = $this->requestEndpoint($endpoint);

        $data = $response->getData();
        // set autogenerated id to document
        if (($doc->isAutoPopulate()
                || $this->getIndex()->getClient()->getConfigValue(['document', 'autoPopulate'], false))
            && $response->isOk()
        ) {
            if (!$doc->hasId()) {
                if (isset($data['_id'])) {
                    $doc->setId($data['_id']);
                }
            }
            if (isset($data['_version'])) {
                $doc->setVersion($data['_version']);
            }
        }

        return $response;
    }

    /**
     * @param $object
     * @param Document $doc
     *
     * @throws Exception\RuntimeException
     *
     * @return Response
     */
    public function addObject($object, Document $doc = null)
    {
        if (!isset($this->_serializer)) {
            throw new RuntimeException('No serializer defined');
        }

        $data = call_user_func($this->_serializer, $object);
        if (!$doc) {
            $doc = new Document();
        }
        $doc->setData($data);

        return $this->addDocument($doc);
    }

    /**
     * Update document, using update script. Requires elasticsearch >= 0.19.0.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html
     *
     * @param \Elastica\Document|\Elastica\Script\AbstractScript $data    Document with update data
     * @param array                                              $options array of query params to use for query. For possible options check es api
     *
     * @throws \Elastica\Exception\InvalidException
     *
     * @return \Elastica\Response
     */
    public function updateDocument($data, array $options = [])
    {
        if (!($data instanceof Document) && !($data instanceof AbstractScript)) {
            throw new \InvalidArgumentException('Data should be a Document or Script');
        }

        if (!$data->hasId()) {
            throw new InvalidException('Document or Script id is not set');
        }

        return $this->getIndex()->getClient()->updateDocument(
            $data->getId(),
            $data,
            $this->getIndex()->getName(),
            $this->getName(),
            $options
        );
    }

    /**
     * Uses _bulk to send documents to the server.
     *
     * @param array|\Elastica\Document[] $docs    Array of Elastica\Document
     * @param array                      $options Array of query params to use for query. For possible options check es api
     *
     * @return \Elastica\Bulk\ResponseSet
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
     */
    public function updateDocuments(array $docs, array $options = [])
    {
        foreach ($docs as $doc) {
            $doc->setType($this->getName());
        }

        return $this->getIndex()->updateDocuments($docs, $options);
    }

    /**
     * Uses _bulk to send documents to the server.
     *
     * @param array|\Elastica\Document[] $docs    Array of Elastica\Document
     * @param array                      $options Array of query params to use for query. For possible options check es api
     *
     * @return \Elastica\Bulk\ResponseSet
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
     */
    public function addDocuments(array $docs, array $options = [])
    {
        foreach ($docs as $doc) {
            $doc->setType($this->getName());
        }

        return $this->getIndex()->addDocuments($docs, $options);
    }

    /**
     * Uses _bulk to send documents to the server.
     *
     * @param object[] $objects
     * @param array    $options Array of query params to use for query. For possible options check es api
     *
     * @return Bulk\ResponseSet
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
     */
    public function addObjects(array $objects, array $options = [])
    {
        if (!isset($this->_serializer)) {
            throw new RuntimeException('No serializer defined');
        }

        $docs = [];
        foreach ($objects as $object) {
            $data = call_user_func($this->_serializer, $object);
            $doc = new Document();
            $doc->setData($data);
            $doc->setType($this->getName());
            $docs[] = $doc;
        }

        return $this->getIndex()->addDocuments($docs, $options);
    }

    /**
     * Get the document from search index.
     *
     * @param string $id      Document id
     * @param array  $options options for the get request
     *
     * @throws \Elastica\Exception\NotFoundException
     * @throws \Elastica\Exception\ResponseException
     *
     * @return \Elastica\Document
     */
    public function getDocument($id, $options = [])
    {
        $endpoint = new \Elasticsearch\Endpoints\Get();
        $endpoint->setID($id);
        $endpoint->setParams($options);

        $response = $this->requestEndpoint($endpoint);
        $result = $response->getData();

        if (!isset($result['found']) || false === $result['found']) {
            throw new NotFoundException('doc id '.$id.' not found');
        }

        if (isset($result['fields'])) {
            $data = $result['fields'];
        } elseif (isset($result['_source'])) {
            $data = $result['_source'];
        } else {
            $data = [];
        }

        $document = new Document($id, $data, $this->getName(), $this->getIndex());
        $document->setVersion($result['_version']);

        return $document;
    }

    /**
     * @param string       $id
     * @param array|string $data
     *
     * @return Document
     */
    public function createDocument($id = '', $data = [])
    {
        $document = new Document($id, $data);
        $document->setType($this);

        return $document;
    }

    /**
     * Returns the type name.
     *
     * @return string Type name
     */
    public function getName()
    {
        return $this->_name;
    }

    /**
     * Sets value type mapping for this type.
     *
     * @param \Elastica\Type\Mapping|array $mapping Elastica\Type\MappingType object or property array with all mappings
     * @param array                        $query   querystring when put mapping (for example update_all_types)
     *
     * @return \Elastica\Response
     */
    public function setMapping($mapping, array $query = [])
    {
        $mapping = Mapping::create($mapping);
        $mapping->setType($this);

        return $mapping->send($query);
    }

    /**
     * Returns current mapping for the given type.
     *
     * @return array Current mapping
     */
    public function getMapping()
    {
        $response = $this->requestEndpoint(new Get());
        $data = $response->getData();

        $mapping = array_shift($data);
        if (isset($mapping['mappings'])) {
            return $mapping['mappings'];
        }

        return [];
    }

    /**
     * Create search object.
     *
     * @param string|array|\Elastica\Query $query   Array with all query data inside or a Elastica\Query object
     * @param int|array                    $options OPTIONAL Limit or associative array of options (option=>value)
     * @param BuilderInterface             $builder
     *
     * @return Search
     */
    public function createSearch($query = '', $options = null, BuilderInterface $builder = null)
    {
        $search = $this->getIndex()->createSearch($query, $options, $builder);
        $search->addType($this);

        return $search;
    }

    /**
     * Do a search on this type.
     *
     * @param string|array|\Elastica\Query $query   Array with all query data inside or a Elastica\Query object
     * @param int|array                    $options OPTIONAL Limit or associative array of options (option=>value)
     *
     * @return \Elastica\ResultSet with all results inside
     *
     * @see \Elastica\SearchableInterface::search
     */
    public function search($query = '', $options = null)
    {
        $search = $this->createSearch($query, $options);

        return $search->search();
    }

    /**
     * Count docs by query.
     *
     * @param string|array|\Elastica\Query $query Array with all query data inside or a Elastica\Query object
     *
     * @return int number of documents matching the query
     *
     * @see \Elastica\SearchableInterface::count
     */
    public function count($query = '')
    {
        $search = $this->createSearch($query);

        return $search->count();
    }

    /**
     * Returns index client.
     *
     * @return \Elastica\Index Index object
     */
    public function getIndex()
    {
        return $this->_index;
    }

    /**
     * @param \Elastica\Document $document
     *
     * @return \Elastica\Response
     */
    public function deleteDocument(Document $document)
    {
        $options = $document->getOptions(
            [
                'version',
                'version_type',
                'routing',
                'parent',
                'replication',
                'consistency',
                'refresh',
                'timeout',
            ]
        );

        return $this->deleteById($document->getId(), $options);
    }

    /**
     * Uses _bulk to delete documents from the server.
     *
     * @param array|\Elastica\Document[] $docs Array of Elastica\Document
     *
     * @return \Elastica\Bulk\ResponseSet
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html
     */
    public function deleteDocuments(array $docs)
    {
        foreach ($docs as $doc) {
            $doc->setType($this->getName());
        }

        return $this->getIndex()->deleteDocuments($docs);
    }

    /**
     * Deletes an entry by its unique identifier.
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html
     *
     * @param int|string $id      Document id
     * @param array      $options
     *
     * @throws \InvalidArgumentException
     * @throws \Elastica\Exception\NotFoundException
     *
     * @return \Elastica\Response Response object
     */
    public function deleteById($id, array $options = [])
    {
        if (empty($id) || !trim($id)) {
            throw new \InvalidArgumentException();
        }

        $endpoint = new Delete();
        $endpoint->setID($id);
        $endpoint->setParams($options);

        $response = $this->requestEndpoint($endpoint);

        $responseData = $response->getData();

        if (isset($responseData['result']) && 'not_found' == $responseData['result']) {
            throw new NotFoundException('Doc id '.$id.' not found and can not be deleted');
        }

        return $response;
    }

    /**
     * Deletes the given list of ids from this type.
     *
     * @param array       $ids
     * @param string|bool $routing Optional routing key for all ids
     *
     * @return \Elastica\Response Response  object
     */
    public function deleteIds(array $ids, $routing = false)
    {
        return $this->getIndex()->getClient()->deleteIds($ids, $this->getIndex(), $this, $routing);
    }

    /**
     * Deletes entries in the db based on a query.
     *
     * @param \Elastica\Query|\Elastica\Query\AbstractQuery|string|array $query   Query object
     * @param array                                                      $options Optional params
     *
     * @return \Elastica\Response
     *
     * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html
     */
    public function deleteByQuery($query, array $options = [])
    {
        $query = Query::create($query);

        $endpoint = new DeleteByQuery();
        $endpoint->setBody($query->toArray());
        $endpoint->setParams($options);

        return $this->requestEndpoint($endpoint);
    }

    /**
     * Makes calls to the elasticsearch server based on this type.
     *
     * @param string $path   Path to call
     * @param string $method Rest method to use (GET, POST, DELETE, PUT)
     * @param array  $data   OPTIONAL Arguments as array
     * @param array  $query  OPTIONAL Query params
     *
     * @return \Elastica\Response Response object
     */
    public function request($path, $method, $data = [], array $query = [])
    {
        $path = $this->getName().'/'.$path;

        return $this->getIndex()->request($path, $method, $data, $query);
    }

    /**
     * Makes calls to the elasticsearch server with usage official client Endpoint based on this type.
     *
     * @param AbstractEndpoint $endpoint
     *
     * @return Response
     */
    public function requestEndpoint(AbstractEndpoint $endpoint)
    {
        $cloned = clone $endpoint;
        $cloned->setType($this->getName());

        return $this->getIndex()->requestEndpoint($cloned);
    }

    /**
     * Sets the serializer callable used in addObject.
     *
     * @see \Elastica\Type::addObject
     *
     * @param array|string $serializer @see \Elastica\Type::_serializer
     *
     * @return $this
     */
    public function setSerializer($serializer)
    {
        $this->_serializer = $serializer;

        return $this;
    }

    /**
     * Checks if the given type exists in Index.
     *
     * @return bool True if type exists
     */
    public function exists()
    {
        $response = $this->requestEndpoint(new Exists());

        return 200 === $response->getStatus();
    }
}
