<?php

// TODO: ar trebui sa se controleze mai eficient cache-ul (cand se modifica o entitate de ex un pasager, sa se goleasca cheile care tin matcing de pasageri ).

// TODO: atributul $entityClassName din clasele copii ar putea fi eliminat pentru ca se poate genera automat - de verificat inainte!

namespace App\EntityMatch;

/**
 * The class is used for matching different entities by certain criteria (each entity has its own
 * criteria). You can search for one or more at the same time and you may choose to get a 'fast'
 * result or 'union' - 'fast' will stop after the first criterion returns something.
 * @author Gabriel
 */
abstract class BaseAbstract
{

	/**
	 * The type of cache used
	 * @var String
	 */
	const CACHE_TYPE = 'entityMatch';

	/**
	 * Indicates if the execution will use cache before searching the database.
	 * @var Boolean
	 */
	protected $useCacheinSqls;

	/**
	 * The cache time to live (in seconds) - 0 for unlimited time.
	 * @var Int
	 */
	protected $cacheTtl = 900;

	/**
	 * The match mode - can be 'union', 'fast' or 'intersect'. (union = searches all criteria without
	 * stopping; 'fast' = searches all criteria but stops when it founds something (does NOT
	 * guarantee that only one matche is returned); 'intersect' will include only records that are
	 * matched by all the criteria - if a criterion does not return anything it is skipped.
	 * The matching will NOT work unless this var is set.
	 * @var String
	 */
	protected $mode;

	/**
	 * Stores the criteria list for matching (field name, value and useWildcard). Each element represents
	 * an associative array with the keys: 'field', 'value' and 'useWildcard'.
	 * @var array
	 */
	protected $criteriaList = [];

	/**
	 * Includes the fields that are exported from the entity besides the id. The names must be in
	 * clear format, as "`" are added automatically.
	 * @var array
	 */
	protected $exportFields = [];

	/**
	 * The database table name for the current entity.
	 * @var String
	 */
	protected $tableName;

	/**
	 * The db table primary key name (most of them have 'id').
	 * @var String
	 */
	protected $tablePrimaryKey='id';

	/**
	 * Extra filtering that is done when searching for matched entities. Ex: the ones that are
	 * deleted, duplicated, etc will be skipped.
	 * @var String
	 */
	protected $matchingFilteringSQL = 'AND ent.deleted=0';

	/**
	 * The class name which will instantiate the Entity - used for primaryKey search.
	 * @var String
	 */
	protected $entityClassName = '';

	/**
	 * The flag indicates if the search was executed or not (if it wasn't, the get[...] methods
	 * are not available.
	 * @var Boolean
	 */
	protected $searchExecuted = false;

	/**
	 * Each criterion must NOT return more than this number or results.
	 * @var Int
	 */
	protected $limitResultsPerCriterion = 100;

	/**
	 * The bulk matches as collected in the first step by the search() method.
	 * @var array
	 */
	protected $bulkMatches = [];

	/**
	 * All the bulk matches without any extra filtering (specific to each entity); this is used
	 * only for suggestions, not the actual matching.
	 * @var array
	 */
	protected $bulkMatchesFull = [];

	/**
	 * An associative array of matches (the key is the id of the entity; the values represent
	 * specific fields per entity - stored in $this->exportFields + the primary key + some internal vars).
	 * @var array
	 */
	protected $matches = [];

	/**
	 * Associative array of matches (witout filtering) - similar structure with $this->matches.
	 * @var array
	 */
	protected $matchesFull = [];


	/**
	 * The class constructor - sets the table name (and also checks it); optionally you can set
	 * the match mode.
	 * @param string $matchMode - can be 'fast/union/intersect.
	 * @throws \Exception - if the table name was not set.
	 */
	public function __construct($matchMode=null, $useCacheinSqls=true)
	{

		// set the match mode - if set
		if ($matchMode) $this->setMatchMode($matchMode);

		// set the table name
		$this->setTableName();
		// check that the table name is correct
		if (!$this->tableName) {
			throw new \Exception('You did not set the database table name in `'.get_called_class().'`.');
		}

		// indicate if the SQLs will use cache
		$this->setUseCacheInSqls($useCacheinSqls);

	}

	/**
	 * Sets the internal attribute $this->tableName
	 */
	abstract protected function setTableName() ;

	/**
	 * The method is used for resetting the match mode.
	 * @param String $matchMode - can be 'fast/union/intersect.
	 * @throws \Exception - if an invalid mode is set.
	 */
	public function setMatchMode($matchMode)
	{
		// check search mode
		if (!$matchMode) {
			throw new \Exception('You did not set the match mode forr `'.get_called_class().'`.');
		}
		if ($matchMode!='fast' and $matchMode!='union' and $matchMode!='intersect') {
			throw new \Exception('The match mode ('.$matchMode.') is not valid. Use `fast`, `union` or `intersect`.');
		}
		//set the search mode
		$this->mode = $matchMode;
	}

	/**
	 * Sets the the 'useCacheinSqls' attribute.
	 * @param Boolean $useCache
	 */
	public function setUseCacheInSqls($useCacheinSqls)
	{
		$this->useCacheinSqls = $useCacheinSqls;
	}

	/**
	 * Adds a new search criterion depending on entity id.
	 * @param Mixed $id
	 */
	public function setId($id)
	{
		$this->setSearchCriterion($this->tablePrimaryKey, $id, false);
	}

	/**
	 * Internal method used for adding a new search criterion to the queue.
	 * @param String $fieldName - the name of the field which is searched.
	 * @param Mixed $fieldValue - the value of the field
	 * @param Boolean $useWildcard - indicates if the right wildcard (%) is used or not for this search)
	 * @param Array $options - some extra options (used for example with remarks)
	 * @throws \Exception - if the input is not correct.
	 */
	protected function setSearchCriterion($fieldName, $fieldValue, $useWildcard, Array $options=[])
	{

		// CHECK INPUT ----------------------------------------------------------------------------
		if (!$fieldName) {
			throw new \Exception('You did not set the field name in `'.get_called_class().'`');
		}
		if (!$fieldValue) {
			throw new \Exception('You did not set the field value in `'.get_called_class().'`');
		}
		if (is_string($fieldValue) and !strlen($fieldValue)) {
			throw new \Exception('You did not set the field value in `'.get_called_class().'`');
		}
		if ($useWildcard===null) {
			throw new \Exception('You did not set the `useWildcard` in `'.get_called_class().'` for field `'.$fieldName.'`');
		}

		// STORE THE CRITERION --------------------------------------------------------------------
		$this->criteriaList[]= ['field'=>$fieldName, 'value'=>$fieldValue, 'useWildcard'=>$useWildcard, 'options'=>$options];

	}

	/**
	 * Executes the actual search. All matches are stored in $this->matches.
	 * @throws \Exception - if input is not okay.
	 */
	public function search()
	{

		// set the flag indicating that the search was executed
		$this->searchExecuted = true;

		// CHECK INPUT ----------------------------------------------------------------------------
		if (!$this->mode) throw new \Exception('You did not set the search mode in `'.get_called_class().'`.');
		if (!$this->criteriaList) return ;

		// LOOP THROUGH EACH CRITERIA AND DO THE SEARCH -------------------------------------------
		foreach($this->criteriaList as $criteria) {
			$matches=$this->dispatchSearchRecords($criteria['field'], $criteria['value'], $criteria['useWildcard'], (Array)$criteria['options']);
			// if some matches found - add them to the list
			if ($matches) {
				$this->collectMatches($matches, $criteria['field']);
			}
			// if some matches found by now - stop if 'fast' mode was set
			if ($this->bulkMatches and $this->mode=='fast') {
				break;
			}
		}

		// store ALL the matches that were found until now (no filtering applied yet)
		$this->bulkMatchesFull = $this->bulkMatches;

		// CLEANS THE MATCHES (FILTERS UN-NEEDED RECORDS) -----------------------------------------
		if ($this->bulkMatches) {
			$this->cleanMatches();
		}

		// PROCESS THE MATCHES DEPENDING ON MODE --------------------------------------------------
		$this->processMatches();

		return $this;
	}

	/**
	 * Adds new matches to the already found matches (it skips duplicates).
	 * @param Array $matches
	 * @param String $searchField - the current field name used for matching.
	 */
	protected function collectMatches(Array $matches, $searchField)
	{

		// loop through the found matches and adds them to the matches list
		if ($matches) {
			$theseMatches = [];
			foreach($matches as $id=>$vars) {
				$theseMatches[$id]=$vars;
				$theseMatches[$id]['_matchedField']=$searchField;
			}
			$this->bulkMatches[] = $theseMatches;
		}
	}


	/**
	 * Checks the type of field and runs the corresponding search - this method should be extended
	 * in the concrete classes to extend the custom fields search.
	 * @param String $field - the field name
	 * @param Mixed $value - the field value
	 * @param Boolean $useWildcard
	 * @param Array $options
	 * @return Array - associative array of 'id' => details array
	 */
	protected function dispatchSearchRecords($field, $value, $useWildcard, Array $options=[])
	{

		// IF THE SEARCH IS DONE BY THE PRIMARY KEY - USE THE ENTITY METHOD -----------------------
		if ($field==$this->tablePrimaryKey) {
			return $this->searchRecord_primaryKey($value);
		}
		// OTHERWISE - USE THE STANDARD SEARCH ----------------------------------------------------
		else {
			return $this->searchRecords_standard($field, $value, $useWildcard, $options);
		}

	}

	/**
	 * Tries to match usikng the optimized/cached Entity call. No need for wildcards and extra options.
	 * @param String $value
	 */
	protected function searchRecord_primaryKey($value)
	{

		// check that the entity class name was defined
		if (!$this->entityClassName) {
			throw new \Exception('You did not define the `entityClassName` attribute for class `'.get_called_class().'`.');
		}

		// get its details
		$className = $this->entityClassName;
		$entObj = $className::select($value);
		// check if the record exists
		if (!$entObj->recordExists()) {
			return [];
		}

		// if some record exists - select the details and return them
		$return=[];
		$return[$value]= [$this->tablePrimaryKey=>$value];
		foreach($this->exportFields as $fld) {
			$return[$value][$fld] = $entObj->$fld;
		}

		return $return;

	}

	/**
	 * The database search for a certain field.
	 * @param String $field - the search field
	 * @param Mixed $value - the valus of the field
	 * @param Boolean $useWildcard
	 * @param Array $options
	 * @return Array - the results found for the current search.
	 */
	protected function searchRecords_standard($field, $value, $useWildcard, Array $options=[])
	{

		// COMPOSE SQL ----------------------------------------------------------------------------

		// the cols list
		$colsSQL=$this->getSelectingFieldsSQL();

		// the search string
		$searchString = $value;
		if ($useWildcard) $searchString .= '%';

		// start the sql
		$searchSQL = "SELECT {$colsSQL} FROM `{$this->tableName}` AS ent WHERE 1 {$this->matchingFilteringSQL}";
		// add the field to be searched
		if ($options['duplicateColumn']) {
			$searchSQL .= " AND (`ent`.`{$field}` LIKE ? OR `ent`.`{$options['duplicateColumn']}` LIKE ?)";
			$sqlVals= [$searchString, $searchString];
		} else {
			$searchSQL .= " AND `ent`.`{$field}` LIKE ?";
			$sqlVals= [$searchString];
		}
		// set the limit
		$searchSQL .= ' LIMIT '.$this->limitResultsPerCriterion;


		// EXECUTE THE SQL, CACHE THE RESULT AND RETURN THE ARRAY RESULT --------------------------
		return $this->searchRecordsRunSQL($searchSQL, $sqlVals);

	}

	/**
	 * Executes an SQL and returns the result (also checks the cache).
	 * @param String $sql
	 * @param Array $sqlVals
	 * @throws \DBException - if the sql execution fails
	 * @return Array - the result
	 */
	protected function searchRecordsRunSQL($sql, Array $sqlVals=[])
	{

		// CHECK CACHE ----------------------------------------------------------------------------
		if ($this->useCacheinSqls) {

			// init cache
			$cacheKey = 'EntityMatch-'.get_called_class().':'.$sql.serialize($sqlVals);
			$cachedMatches = \Engine\Cache\General::getInstance(self::CACHE_TYPE)->get($cacheKey);

			// if something cached - return the cached result
			if ($cachedMatches!==null) {
				return $cachedMatches;
			}

		}

		// GET THE INFO FROM THE DATABASE ---------------------------------------------------------

		// get db connector
		$db=\DB::getInstance();

		// execute the sql
		$searchEx = $db->Execute($sql, $sqlVals);

		// check the sql
		if (!$searchEx) {
			\Log::getInstance()->dbError("Could not execute the matching for an entity :  ".get_called_class());
			throw new \DBException('The matching for class `'.get_called_class().'` could not be executed.');
		}

		// CLEAN AND RETURN THE SEARCH OUTPUT (CACHE IT FIRST) ------------------------------------
		$return = [];
		foreach($searchEx as $row) {
			$return[$row[$this->tablePrimaryKey]] = $row;
		}

		// cache the result (if activated) --------------------------------------------------------
		if ($this->useCacheinSqls) {
			\Engine\Cache\General::getInstance(self::CACHE_TYPE)->set($cacheKey, $return, $this->cacheTtl);
		}

		return $return;

	}

	/**
	 * Cleans (filters) the $this->bulkMatches. By default we do not do anything, but certain
	 * entities need to apply certain filters.
	 */
	protected function cleanMatches()
	{

	}


	/**
	 * Fills in the $this->matches (from $this->bulkMatches) and $this->matchesFull (from $this->bulkMatchesFull).
	 */
	private function processMatches()
	{

		// FOR $this->matches /////////////////////////////////////////////////////////////////////


		// MODE = fast/union - PERFORM AN UNION OF ENTITIES ---------------------------------------
		if ($this->mode!='intersect') {
			$this->matches = [];
			foreach ($this->bulkMatches as $matches) {
				foreach ($matches as $id=>$details) {
					if (!$this->matches[$id]) {
						$this->matches[$id] = $details;
					}
				}
			}
		}
		// MODE = intersect - PERFORM AN INTERSECTION OF ENTITIES ---------------------------------
		else {
			if ($this->bulkMatches) {
				$this->matches = reset($this->bulkMatches);
				foreach ($this->bulkMatches as $matches) {
					$this->matches = array_intersect_key($this->matches, $matches);
				}
			}
		}

		// FOR $this->matchesFull (UNION OF ALL MATCHES) //////////////////////////////////////////
		$this->matchesFull = [];
		foreach ($this->bulkMatchesFull as $matches) {
			foreach ($matches as $id=>$details) {
				if (!key_exists($id, $this->matchesFull)) {
					$this->matchesFull[$id] = $details;
				}
			}
		}

	}

	/**
	 * Gets all the found matches (with filterng applied).
	 * @throws \Exception - if the search was not executed.
	 * @return Array - associative array of id=>details
	 */
	public function getAll()
	{
		// check if the search was executed
		if (!$this->searchExecuted) {
			throw new \Exception('You did NOT start the search execution for `'.get_called_class().'`. Please call the `search` method.');
		}
		return $this->matches;
	}

	/**
	 * Returns the number of results found for the current search.
	 * @throws \Exception - if the search was not executed.
	 * @return Int - the number of results
	 */
	public function getResultsCount()
	{
			// check if the search was executed
		if (!$this->searchExecuted) {
			throw new \Exception('You did NOT start the search execution for `'.get_called_class().'`. Please call the `search` method.');
		}
		return count($this->matches);
	}

	/**
	 * Returns the first match from the list.
	 * @throws \Exception - if the search was not executed.
	 * @return Mixed|NULL - the first entity id found or null if no results found.
	 */
	public function getFirstMatchId()
	{
		// check if the search was executed
		if (!$this->searchExecuted) {
			throw new \Exception('You did NOT start the search execution for `'.get_called_class().'`. Please call the `search` method.');
		}
		if ($this->matches) {
			reset($this->matches);
			return key($this->matches);
		} else {
			return null;
		}
	}

	/**
	 * Returns the details of the first match.
	 * @throws \Exception - if the search was not executed.
	 * @return Array|NULL - the list of details for the first match.
	 */
	public function getFirstMatchDetails()
	{
		// check if the search was executed
		if (!$this->searchExecuted) {
			throw new \Exception('You did NOT start the search execution for `'.get_called_class().'`. Please call the `search` method.');
		}

		if ($this->matches) {
			return $this->matches[ $this->getFirstMatchId() ];
		} else {
			return [];
		}
	}

	/**
	 * Returns the list of criteria.
	 * @return Array
	 */
	public function getCriteriaList()
	{
		return $this->criteriaList;
	}

	/**
	 * Gets all the found matches (without filtering applied).
	 * @throws \Exception - if the search was not executed.
	 * @return Array - associative array of id=>details
	 */
	public function getAllFull()
	{
		// check if the search was executed
		if (!$this->searchExecuted) {
			throw new \Exception('You did NOT start the search execution for `'.get_called_class().'`. Please call the `search` method.');
		}
		return $this->matchesFull;
	}

	/**
	 * Returns the list of fields to be exported together with the primary key column (all
	 * "alias-ed" by 'ent' as table name).
	 * @return string
	 */
	protected function getSelectingFieldsSQL()
	{
		$fieldsList = ["`ent`.`{$this->tablePrimaryKey}`"];
		foreach($this->exportFields as $fld) {
			$fieldsList[]="`ent`.`{$fld}`";
		}
		return implode(', ', $fieldsList);
	}

}