<?php
/**
 * @link http://www.yiiframework.com/
 * @copyright Copyright (c) 2008 Yii Software LLC
 * @license http://www.yiiframework.com/license/
 */

namespace yii\mongo;

use yii\base\InvalidParamException;
use yii\base\Object;
use Yii;

/**
 * Collection represents the Mongo collection information.
 *
 * @author Paul Klimov <klimov.paul@gmail.com>
 * @since 2.0
 */
class Collection extends Object
{
	/**
	 * @var \MongoCollection Mongo collection instance.
	 */
	public $mongoCollection;

	/**
	 * Drops this collection.
	 */
	public function drop()
	{
		$this->mongoCollection->drop();
	}

	/**
	 * @param array $condition
	 * @param array $fields
	 * @return \MongoCursor
	 */
	public function find($condition = [], $fields = [])
	{
		return $this->mongoCollection->find($this->buildCondition($condition), $fields);
	}

	/**
	 * @param array $condition
	 * @param array $fields
	 * @return array
	 */
	public function findAll($condition = [], $fields = [])
	{
		$cursor = $this->find($condition, $fields);
		$result = [];
		foreach ($cursor as $data) {
			$result[] = $data;
		}
		return $result;
	}

	/**
	 * Inserts new data into collection.
	 * @param array|object $data data to be inserted.
	 * @param array $options list of options in format: optionName => optionValue.
	 * @return \MongoId new record id instance.
	 * @throws Exception on failure.
	 */
	public function insert($data, $options = [])
	{
		$token = 'Inserting data into ' . $this->mongoCollection->getName();
		Yii::info($token, __METHOD__);
		try {
			Yii::beginProfile($token, __METHOD__);
			$options = array_merge(['w' => 1], $options);
			$this->tryResultError($this->mongoCollection->insert($data, $options));
			Yii::endProfile($token, __METHOD__);
			return is_array($data) ? $data['_id'] : $data->_id;
		} catch (\Exception $e) {
			Yii::endProfile($token, __METHOD__);
			throw new Exception($e->getMessage(), (int)$e->getCode(), $e);
		}
	}

	/**
	 * Inserts several new rows into collection.
	 * @param array $rows array of arrays or objects to be inserted.
	 * @param array $options list of options in format: optionName => optionValue.
	 * @return array inserted data, each row will have "_id" key assigned to it.
	 * @throws Exception on failure.
	 */
	public function batchInsert($rows, $options = [])
	{
		$token = 'Inserting batch data into ' . $this->mongoCollection->getName();
		Yii::info($token, __METHOD__);
		try {
			Yii::beginProfile($token, __METHOD__);
			$options = array_merge(['w' => 1], $options);
			$this->tryResultError($this->mongoCollection->batchInsert($rows, $options));
			Yii::endProfile($token, __METHOD__);
			return $rows;
		} catch (\Exception $e) {
			Yii::endProfile($token, __METHOD__);
			throw new Exception($e->getMessage(), (int)$e->getCode(), $e);
		}
	}

	/**
	 * Updates the rows, which matches given criteria by given data.
	 * @param array $condition description of the objects to update.
	 * @param array $newData the object with which to update the matching records.
	 * @param array $options list of options in format: optionName => optionValue.
	 * @return integer|boolean number of updated documents or whether operation was successful.
	 * @throws Exception on failure.
	 */
	public function update($condition, $newData, $options = [])
	{
		$token = 'Updating data in ' . $this->mongoCollection->getName();
		Yii::info($token, __METHOD__);
		try {
			Yii::beginProfile($token, __METHOD__);
			$options = array_merge(['w' => 1, 'multiple' => true], $options);
			if ($options['multiple']) {
				$keys = array_keys($newData);
				if (!empty($keys) && strncmp('$', $keys[0], 1) !== 0) {
					$newData = ['$set' => $newData];
				}
			}
			$condition = $this->buildCondition($condition);
			$result = $this->mongoCollection->update($condition, $newData, $options);
			$this->tryResultError($result);
			Yii::endProfile($token, __METHOD__);
			if (is_array($result) && array_key_exists('n', $result)) {
				return $result['n'];
			} else {
				return true;
			}
		} catch (\Exception $e) {
			Yii::endProfile($token, __METHOD__);
			throw new Exception($e->getMessage(), (int)$e->getCode(), $e);
		}
	}

	/**
	 * Update the existing database data, otherwise insert this data
	 * @param array|object $data data to be updated/inserted.
	 * @param array $options list of options in format: optionName => optionValue.
	 * @return \MongoId updated/new record id instance.
	 * @throws Exception on failure.
	 */
	public function save($data, $options = [])
	{
		$token = 'Saving data into ' . $this->mongoCollection->getName();
		Yii::info($token, __METHOD__);
		try {
			Yii::beginProfile($token, __METHOD__);
			$options = array_merge(['w' => 1], $options);
			$this->tryResultError($this->mongoCollection->save($data, $options));
			Yii::endProfile($token, __METHOD__);
			return is_array($data) ? $data['_id'] : $data->_id;
		} catch (\Exception $e) {
			Yii::endProfile($token, __METHOD__);
			throw new Exception($e->getMessage(), (int)$e->getCode(), $e);
		}
	}

	/**
	 * Removes data from the collection.
	 * @param array $condition description of records to remove.
	 * @param array $options list of options in format: optionName => optionValue.
	 * @return integer|boolean number of updated documents or whether operation was successful.
	 * @throws Exception on failure.
	 */
	public function remove($condition = [], $options = [])
	{
		$token = 'Removing data from ' . $this->mongoCollection->getName();
		Yii::info($token, __METHOD__);
		try {
			Yii::beginProfile($token, __METHOD__);
			$options = array_merge(['w' => 1, 'multiple' => true], $options);
			$result = $this->mongoCollection->remove($this->buildCondition($condition), $options);
			$this->tryResultError($result);
			Yii::endProfile($token, __METHOD__);
			if (is_array($result) && array_key_exists('n', $result)) {
				return $result['n'];
			} else {
				return true;
			}
		} catch (\Exception $e) {
			Yii::endProfile($token, __METHOD__);
			throw new Exception($e->getMessage(), (int)$e->getCode(), $e);
		}
	}

	/**
	 * Checks if command execution result ended with an error.
	 * @param mixed $result raw command execution result.
	 * @throws Exception if an error occurred.
	 */
	protected function tryResultError($result)
	{
		if (is_array($result)) {
			if (!empty($result['err'])) {
				throw new Exception($result['errmsg'], (int)$result['code']);
			}
		} elseif (!$result) {
			throw new Exception('Unknown error, use "w=1" option to enable error tracking');
		}
	}

	/**
	 * Converts user friendly condition keyword into actual Mongo condition keyword.
	 * @param string $key raw condition key.
	 * @return string actual key.
	 */
	protected function normalizeConditionKeyword($key)
	{
		static $map = [
			'or' => '$or',
			'>' => '$gt',
			'>=' => '$gte',
			'<' => '$lt',
			'<=' => '$lte',
			'!=' => '$ne',
			'<>' => '$ne',
			'in' => '$in',
			'not in' => '$nin',
			'all' => '$all',
			'size' => '$size',
			'type' => '$type',
			'exists' => '$exists',
			'notexists' => '$exists',
			'elemmatch' => '$elemMatch',
			'mod' => '$mod',
			'%' => '$mod',
			'=' => '$$eq',
			'==' => '$$eq',
			'where' => '$where'
		];
		$key = strtolower($key);
		if (array_key_exists($key, $map)) {
			return $map[$key];
		} else {
			return $key;
		}
	}

	/**
	 * Builds up Mongo condition from user friendly condition.
	 * @param array $condition raw condition.
	 * @return array normalized Mongo condition.
	 * @throws \yii\base\InvalidParamException on invalid condition given.
	 */
	public function buildCondition($condition)
	{
		if (!is_array($condition)) {
			throw new InvalidParamException('Condition should be an array.');
		}
		$result = [];
		foreach ($condition as $key => $value) {
			if (is_array($value)) {
				$actualValue = $this->buildCondition($value);
			} else {
				$actualValue = $value;
			}
			if (is_numeric($key)) {
				$result[] = $actualValue;
			} else {
				$key = $this->normalizeConditionKeyword($key);
				if (strncmp('$', $key, 1) !== 0 && is_array($actualValue) && array_key_exists(0, $actualValue)) {
					// shortcut for IN condition
					$result[$key]['$in'] = $actualValue;
				} else {
					$result[$key] = $actualValue;
				}
			}
		}
		return $result;
	}
}