<?php
/**
 * @copyright Copyright (c) 2018-2021, Matias De lellis <mati86dl@gmail.com>
 * @copyright Copyright (c) 2018-2019, Branko Kokanovic <branko@kokanovic.org>
 *
 * @author Branko Kokanovic <branko@kokanovic.org>
 *
 * @license GNU AGPL version 3 or any later version
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 */
namespace OCA\FaceRecognition\Db;

use OC\DB\QueryBuilder\Literal;

use OCP\IDBConnection;
use OCP\IUser;

use OCP\AppFramework\Db\QBMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\QueryBuilder\IQueryBuilder;

class PersonMapper extends QBMapper {

	public function __construct(IDBConnection $db) {
		parent::__construct($db, 'facerecog_persons', '\OCA\FaceRecognition\Db\Person');
	}

	/**
	 * @param string $userId ID of the user
	 * @param int $personId ID of the person
	 *
	 * @return Person
	 */
	public function find(string $userId, int $personId): Person {
		$qb = $this->db->getQueryBuilder();
		$qb->select('id', 'name', 'is_visible')
			->from($this->getTableName(), 'p')
			->where($qb->expr()->eq('id', $qb->createNamedParameter($personId)))
			->andWhere($qb->expr()->eq('user', $qb->createNamedParameter($userId)));
		return $this->findEntity($qb);
	}

	/**
	 * @param string $userId ID of the user
	 * @param int $modelId ID of the model
	 * @param string $personName name of the person to find
	 * @return Person[]
	 */
	public function findByName(string $userId, int $modelId, string $personName): array {
		$sub = $this->db->getQueryBuilder();
		$sub->select(new Literal('1'))
			->from('facerecog_faces', 'f')
			->innerJoin('f', 'facerecog_images' ,'i', $sub->expr()->eq('f.image', 'i.id'))
			->where($sub->expr()->eq('p.id', 'f.person'))
			->andWhere($sub->expr()->eq('i.user', $sub->createParameter('user_id')))
			->andWhere($sub->expr()->eq('i.model', $sub->createParameter('model_id')))
			->andWhere($sub->expr()->eq('p.name', $sub->createParameter('person_name')));

		$qb = $this->db->getQueryBuilder();
		$qb->select('id', 'name', 'is_valid')
			->from($this->getTableName(), 'p')
			->where('EXISTS (' . $sub->getSQL() . ')')
			->setParameter('user_id', $userId)
			->setParameter('model_id', $modelId)
			->setParameter('person_name', $personName);

		return $this->findEntities($qb);
	}

	/**
	 * @param string $userId ID of the user
	 * @param int $modelId ID of the model
	 * @return Person[]
	 */
	public function findUnassigned(string $userId, int $modelId): array {
		$sub = $this->db->getQueryBuilder();
		$sub->select(new Literal('1'))
			->from('facerecog_faces', 'f')
			->innerJoin('f', 'facerecog_images' ,'i', $sub->expr()->eq('f.image', 'i.id'))
			->where($sub->expr()->eq('p.id', 'f.person'))
			->andWhere($sub->expr()->eq('i.user', $sub->createParameter('user_id')))
			->andWhere($sub->expr()->eq('i.model', $sub->createParameter('model_id')));

		$qb = $this->db->getQueryBuilder();
		$qb->select('id', 'is_valid')
			->from($this->getTableName(), 'p')
			->where('EXISTS (' . $sub->getSQL() . ')')
			->andWhere($qb->expr()->eq('is_valid', $qb->createParameter('is_valid')))
			->andWhere($qb->expr()->eq('is_visible', $qb->createParameter('is_visible')))
			->andWhere($qb->expr()->isNull('name'))
			->setParameter('user_id', $userId)
			->setParameter('model_id', $modelId)
			->setParameter('is_valid', true, IQueryBuilder::PARAM_BOOL)
			->setParameter('is_visible', true, IQueryBuilder::PARAM_BOOL);

		return $this->findEntities($qb);
	}

	/**
	 * @param string $userId ID of the user
	 * @param int $modelId ID of the model
	 * @return Person[]
	 */
	public function findIgnored(string $userId, int $modelId): array {
		$sub = $this->db->getQueryBuilder();
		$sub->select(new Literal('1'))
			->from('facerecog_faces', 'f')
			->innerJoin('f', 'facerecog_images' ,'i', $sub->expr()->eq('f.image', 'i.id'))
			->where($sub->expr()->eq('p.id', 'f.person'))
			->andWhere($sub->expr()->eq('i.user', $sub->createParameter('user_id')))
			->andWhere($sub->expr()->eq('i.model', $sub->createParameter('model_id')));

		$qb = $this->db->getQueryBuilder();
		$qb->select('id', 'is_valid')
			->from($this->getTableName(), 'p')
			->where('EXISTS (' . $sub->getSQL() . ')')
			->andWhere($qb->expr()->eq('is_valid', $qb->createParameter('is_valid')))
			->andWhere($qb->expr()->eq('is_visible', $qb->createParameter('is_visible')))
			->andWhere($qb->expr()->isNull('name'))
			->setParameter('user_id', $userId)
			->setParameter('model_id', $modelId)
			->setParameter('is_valid', true, IQueryBuilder::PARAM_BOOL)
			->setParameter('is_visible', false, IQueryBuilder::PARAM_BOOL);

		return $this->findEntities($qb);
	}

	/**
	 * @param string $userId ID of the user
	 * @param int $modelId ID of the model
	 * @return Person[]
	 */
	public function findAll(string $userId, int $modelId): array {
		$sub = $this->db->getQueryBuilder();
		$sub->select(new Literal('1'))
			->from('facerecog_faces', 'f')
			->innerJoin('f', 'facerecog_images' ,'i', $sub->expr()->eq('f.image', 'i.id'))
			->where($sub->expr()->eq('p.id', 'f.person'))
			->andWhere($sub->expr()->eq('i.user', $sub->createParameter('user_id')))
			->andWhere($sub->expr()->eq('i.model', $sub->createParameter('model_id')));

		$qb = $this->db->getQueryBuilder();
		$qb->select('id', 'name', 'is_valid')
			->from($this->getTableName(), 'p')
			->where('EXISTS (' . $sub->getSQL() . ')')
			->setParameter('user_id', $userId)
			->setParameter('model_id', $modelId);

		return $this->findEntities($qb);
	}

	/**
	 * @param string $userId ID of the user
	 *
	 * @return Person[]
	 */
	public function findDistinctNames(string $userId, int $modelId): array {
		$qb = $this->db->getQueryBuilder();
		$qb->selectDistinct('name')
			->from($this->getTableName(), 'p')
			->innerJoin('p', 'facerecog_faces' , 'f', $qb->expr()->eq('f.person', 'p.id'))
			->innerJoin('f', 'facerecog_images' ,'i', $qb->expr()->eq('f.image', 'i.id'))
			->where($qb->expr()->eq('i.user', $qb->createParameter('user_id')))
			->andWhere($qb->expr()->eq('i.model', $qb->createParameter('model_id')))
			->andwhere($qb->expr()->isNotNull('p.name'))
			->setParameter('user_id', $userId)
			->setParameter('model_id', $modelId);
		return $this->findEntities($qb);
	}

	/**
	 * @param string $userId ID of the user
	 *
	 * @return Person[]
	 */
	public function findDistinctNamesSelected(string $userId, int $modelId, $faceNames): array {
		$qb = $this->db->getQueryBuilder();
		$qb->selectDistinct('name')
			->from($this->getTableName(), 'p')
			->innerJoin('p', 'facerecog_faces' , 'f', $qb->expr()->eq('f.person', 'p.id'))
			->innerJoin('f', 'facerecog_images' ,'i', $qb->expr()->eq('f.image', 'i.id'))
			->where($qb->expr()->eq('i.user', $qb->createParameter('user_id')))
			->andWhere($qb->expr()->eq('i.model', $qb->createParameter('model_id')))
			->andwhere($qb->expr()->isNotNull('p.name'))
			->andWhere($qb->expr()->eq('p.name', $qb->createParameter('faceNames')))
			->setParameter('user_id', $userId)
			->setParameter('model_id', $modelId)
			->setParameter('faceNames', $faceNames);
		return $this->findEntities($qb);
	}

	/**
	 * Search Person by name
	 *
	 * @param int|null $offset
	 * @param int|null $limit
	 */
	public function findPersonsLike(string $userId, int $modelId, string $name, ?int $offset = null, ?int $limit = null): array {
		$qb = $this->db->getQueryBuilder();
		$qb->selectDistinct('p.name')
			->from($this->getTableName(), 'p')
			->innerJoin('p', 'facerecog_faces', 'f', $qb->expr()->eq('f.person', 'p.id'))
			->innerJoin('p', 'facerecog_images', 'i', $qb->expr()->eq('f.image', 'i.id'))
			->where($qb->expr()->eq('p.user', $qb->createNamedParameter($userId)))
			->andWhere($qb->expr()->eq('model', $qb->createNamedParameter($modelId)))
			->andWhere($qb->expr()->eq('is_processed', $qb->createNamedParameter(True)))
			->andWhere($qb->expr()->like($qb->func()->lower('p.name'), $qb->createParameter('query')));

		$query = '%' . $this->db->escapeLikeParameter(strtolower($name)) . '%';
		$qb->setParameter('query', $query);

		$qb->setFirstResult($offset);
		$qb->setMaxResults($limit);

		return $this->findEntities($qb);
	}

	/**
	 * Returns count of persons found for a given user.
	 *
	 * @param string $userId ID of the user
	 * @param int $modelId ID of the model
	 * @return int Count of persons
	 */
	public function countPersons(string $userId, int $modelId): int {
		return count($this->findDistinctNames($userId, $modelId));
	}

	/**
	 * Returns count of clusters found for a given user.
	 *
	 * @param string $userId ID of the user
	 * @param int $modelId ID of the model
	 * @param bool $onlyInvalid True if client wants count of invalid clusters only,
	 *  false if client want count of all clusters
	 * @return int Count of clusters
	 */
	public function countClusters(string $userId, int $modelId, bool $onlyInvalid=false): int {
		$sub = $this->db->getQueryBuilder();
		$sub->select(new Literal('1'))
			->from('facerecog_faces', 'f')
			->innerJoin('f', 'facerecog_images' ,'i', $sub->expr()->eq('f.image', 'i.id'))
			->where($sub->expr()->eq('p.id', 'f.person'))
			->andWhere($sub->expr()->eq('i.user', $sub->createParameter('user_id')))
			->andWhere($sub->expr()->eq('i.model', $sub->createParameter('model_id')));

		$qb = $this->db->getQueryBuilder();
		$qb->select($qb->createFunction('COUNT(' . $qb->getColumnName('id') . ')'))
			->from($this->getTableName(), 'p')
			->where('EXISTS (' . $sub->getSQL() . ')');

		if ($onlyInvalid) {
			$qb = $qb
				->andWhere($qb->expr()->eq('is_valid', $qb->createParameter('is_valid')))
				->setParameter('is_valid', false, IQueryBuilder::PARAM_BOOL);
		}

		$qb = $qb
			->setParameter('user_id', $userId)
			->setParameter('model_id', $modelId);

		$resultStatement = $qb->execute();
		$data = $resultStatement->fetch(\PDO::FETCH_NUM);
		$resultStatement->closeCursor();

		return (int)$data[0];
	}

	/**
	 * Based on a given image, takes all faces that belong to that image
	 * and invalidates all person that those faces belongs to.
	 *
	 * @param int $imageId ID of image for which to invalidate persons for
	 *
	 * @return void
	 */
	public function invalidatePersons(int $imageId): void {
		$sub = $this->db->getQueryBuilder();
		$tableNameWithPrefixWithoutQuotes = trim($sub->getTableName($this->getTableName()), '`');
		$sub->select(new Literal('1'));
		$sub->from('facerecog_images', 'i')
			->innerJoin('i', 'facerecog_faces' ,'f', $sub->expr()->eq('i.id', 'f.image'))
			->where($sub->expr()->eq($tableNameWithPrefixWithoutQuotes . '.id', 'f.person'))
			->andWhere($sub->expr()->eq('i.id', $sub->createParameter('image_id')));

		$qb = $this->db->getQueryBuilder();
		$qb->update($this->getTableName())
			->set("is_valid", $qb->createParameter('is_valid'))
			->where('EXISTS (' . $sub->getSQL() . ')')
			->setParameter('image_id', $imageId)
			->setParameter('is_valid', false, IQueryBuilder::PARAM_BOOL)
			->execute();
	}

	/**
	 * Based on current clusters and new clusters, do database reconciliation.
	 * It tries to do that in minimal number of SQL queries. Operation is atomic.
	 *
	 * Clusters are array, where keys are ID of persons, and values are indexed arrays
	 * with values that are ID of the faces for those persons.
	 *
	 * @param string $userId ID of the user that clusters belong to
	 * @param array $currentClusters Current clusters
	 * @param array $newClusters New clusters
	 *
	 * @return void
	 */
	public function mergeClusterToDatabase(string $userId, $currentClusters, $newClusters): void {
		$this->db->beginTransaction();
		$currentDateTime = new \DateTime();

		try {
			// Delete clusters that do not exist anymore
			foreach($currentClusters as $oldPerson => $oldFaces) {
				if (array_key_exists($oldPerson, $newClusters)) {
					continue;
				}

				// OK, we bumped into cluster that existed and now it does not exist.
				// We need to remove all references to it and to delete it.
				foreach ($oldFaces as $oldFace) {
					$this->updateFace($oldFace, null);
				}

				// todo: this is not very cool. What if user had associated linked user to this. And all lost?
				$qb = $this->db->getQueryBuilder();
				// todo: for extra safety, we should probably add here additional condition, where (user=$userId)
				$qb
					->delete($this->getTableName())
					->where($qb->expr()->eq('id', $qb->createNamedParameter($oldPerson)))
					->execute();
			}

			// Modify existing clusters
			foreach($newClusters as $newPerson=>$newFaces) {
				if (!array_key_exists($newPerson, $currentClusters)) {
					// This cluster didn't exist, there is nothing to modify
					// It will be processed during cluster adding operation
					continue;
				}

				$oldFaces = $currentClusters[$newPerson];
				if ($newFaces === $oldFaces) {
					// Set cluster as valid now
					$qb = $this->db->getQueryBuilder();
					$qb
						->update($this->getTableName())
						->set("is_valid", $qb->createParameter('is_valid'))
						->where($qb->expr()->eq('id', $qb->createNamedParameter($newPerson)))
						->setParameter('is_valid', true, IQueryBuilder::PARAM_BOOL)
						->execute();
					continue;
				}

				// OK, set of faces do differ. Now, we could potentially go into finer grain details
				// and add/remove each individual face, but this seems too detailed. Enough is to
				// reset all existing faces to null and to add new faces to new person. That should
				// take care of both faces that are removed from cluster, as well as for newly added
				// faces to this cluster.

				// First remove all old faces from any cluster (reset them to null)
				foreach ($oldFaces as $oldFace) {
					// Reset face to null only if it wasn't moved to other cluster!
					// (if face is just moved to other cluster, do not reset to null, as some other
					// pass for some other cluster will eventually update it to proper cluster)
					if ($this->isFaceInClusters($oldFace, $newClusters) === false) {
						$this->updateFace($oldFace, null);
					}
				}

				// Then set all new faces to belong to this cluster
				foreach ($newFaces as $newFace) {
					$this->updateFace($newFace, $newPerson);
				}

				// Set cluster as valid now
				$qb = $this->db->getQueryBuilder();
				$qb
					->update($this->getTableName())
					->set("is_valid", $qb->createParameter('is_valid'))
					->where($qb->expr()->eq('id', $qb->createNamedParameter($newPerson)))
					->setParameter('is_valid', true, IQueryBuilder::PARAM_BOOL)
					->execute();
			}

			// Add new clusters
			foreach($newClusters as $newPerson=>$newFaces) {
				if (array_key_exists($newPerson, $currentClusters)) {
					// This cluster already existed, nothing to add
					// It was already processed during modify cluster operation
					continue;
				}

				// Create new cluster and add all faces to it
				$qb = $this->db->getQueryBuilder();
				$qb
					->insert($this->getTableName())
					->values([
						'user' => $qb->createNamedParameter($userId),
						'is_valid' => $qb->createNamedParameter(true),
						'last_generation_time' => $qb->createNamedParameter($currentDateTime, IQueryBuilder::PARAM_DATE),
						'linked_user' => $qb->createNamedParameter(null)])
					->execute();
				$insertedPersonId = $qb->getLastInsertId();
				foreach ($newFaces as $newFace) {
					$this->updateFace($newFace, $insertedPersonId);
				}
			}

			$this->db->commit();
		} catch (\Exception $e) {
			$this->db->rollBack();
			throw $e;
		}
	}

	/**
	 * Deletes all persons from that user.
	 *
	 * @param string $userId User to drop persons from a table.
	 *
	 * @return void
	 */
	public function deleteUserPersons(string $userId): void {
		$qb = $this->db->getQueryBuilder();
		$qb->delete($this->getTableName())
			->where($qb->expr()->eq('user', $qb->createNamedParameter($userId)))
			->execute();
	}

	/**
	 * Deletes all persons from that user and model
	 *
	 * @param string $userId ID of user for drop from table
	 * @param int $modelId
	 *
	 * @return void
	 */
	public function deleteUserModel(string $userId, int $modelId): void {
		//TODO: Make it atomic
		$qb = $this->db->getQueryBuilder();
		$qb->delete($this->getTableName())
			->where($qb->expr()->eq('id', $qb->createParameter('person')));

		$persons = $this->findAll($userId, $modelId);
		foreach ($persons as $person) {
			$qb->setParameter('person', $person->getId())->execute();
		}
	}

	/**
	 * Deletes person if it is empty (have no faces associated to it)
	 *
	 * @param int $personId Person to check if it should be deleted
	 *
	 * @return void
	 */
	public function removeIfEmpty(int $personId): void {
		$sub = $this->db->getQueryBuilder();
		$sub->select(new Literal('1'));
		$sub->from('facerecog_faces', 'f')
			->where($sub->expr()->eq('f.person', $sub->createParameter('person')));

		$qb = $this->db->getQueryBuilder();
		$qb->delete($this->getTableName())
			->where($qb->expr()->eq('id', $qb->createParameter('person')))
			->andWhere('NOT EXISTS (' . $sub->getSQL() . ')')
			->setParameter('person', $personId)
			->execute();
	}

	/**
	 * Deletes all persons that have no faces associated to them
	 *
	 * @param string $userId ID of user for which we are deleting orphaned persons
	 */
	public function deleteOrphaned(string $userId): int {
		$sub = $this->db->getQueryBuilder();
		$sub->select(new Literal('1'));
		$sub->from('facerecog_faces', 'f')
			->where($sub->expr()->eq('f.person', 'p.id'));

		$qb = $this->db->getQueryBuilder();
		$qb->select('p.id')
			->from($this->getTableName(), 'p')
			->where($qb->expr()->eq('p.user', $qb->createParameter('user')))
			->andWhere('NOT EXISTS (' . $sub->getSQL() . ')')
			->setParameter('user', $userId);
		$orphanedPersons = $this->findEntities($qb);

		$orphaned = 0;
		foreach ($orphanedPersons as $person) {
			$qb = $this->db->getQueryBuilder();
			$orphaned += $qb->delete($this->getTableName())
				->where($qb->expr()->eq('id', $qb->createNamedParameter($person->id)))
				->execute();
		}
		return $orphaned;
	}

	/*
	 * Mark the cluster as hidden or visible to user.
	 *
	 * @param int $personId ID of the person
	 * @param bool $visible visibility of the person
	 *
	 * @return void
	 */
	public function setVisibility (int $personId, bool $visible): void {
		$qb = $this->db->getQueryBuilder();
		if ($visible) {
			$qb->update($this->getTableName())
				->set('is_visible', $qb->createNamedParameter(1))
				->where($qb->expr()->eq('id', $qb->createNamedParameter($personId)))
				->execute();
		} else {
			$qb->update($this->getTableName())
				->set('is_visible', $qb->createNamedParameter(0))
				->set('name', $qb->createNamedParameter(null))
				->where($qb->expr()->eq('id', $qb->createNamedParameter($personId)))
				->execute();
		}
	}

	/*
	 * Mark the cluster as hidden or visible to user.
	 *
	 * @param int $personId ID of the person
	 * @param int $faceId visibility of the person
	 * @param string|null $name optional name to rename them.
	 *
	 * @return Person
	 */
	public function detachFace(int $personId, int $faceId, $name = null): Person {
		// Mark the face as non groupable.
		$qb = $this->db->getQueryBuilder();
		$qb->update('facerecog_faces')
			->set('is_groupable', $qb->createParameter('is_groupable'))
			->where($qb->expr()->eq('id', $qb->createNamedParameter($faceId)))
			->setParameter('is_groupable', false, IQueryBuilder::PARAM_BOOL)
			->execute();

		if ($this->countClusterFaces($personId) === 1) {
			// If cluster is an single face just rename it.
			$qb = $this->db->getQueryBuilder();
			$qb->update($this->getTableName())
				->set('name', $qb->createNamedParameter($name))
				->set('is_visible', $qb->createNamedParameter(true))
				->where($qb->expr()->eq('id', $qb->createNamedParameter($personId)))
				->execute();
		} else {
			// If there are other faces, must create a new person for that face.
			$qb = $this->db->getQueryBuilder();
			$qb->select('user')
				->from($this->getTableName())
				->where($qb->expr()->eq('id', $qb->createNamedParameter($personId)));
			$oldPerson = $this->findEntity($qb);

			$qb = $this->db->getQueryBuilder();
			$qb->insert($this->getTableName())->values([
				'user' => $qb->createNamedParameter($oldPerson->getUser()),
				'name' => $qb->createNamedParameter($name),
				'is_valid' => $qb->createNamedParameter(true),
				'last_generation_time' => $qb->createNamedParameter(new \DateTime(), IQueryBuilder::PARAM_DATE),
				'linked_user' => $qb->createNamedParameter(null),
				'is_visible' => $qb->createNamedParameter(true)
			])->execute();

			$personId = $qb->getLastInsertId();

			$qb = $this->db->getQueryBuilder();
			$qb->update('facerecog_faces')
				->set('person', $qb->createParameter('person'))
				->where($qb->expr()->eq('id', $qb->createNamedParameter($faceId)))
				->setParameter('person', $personId)
				->execute();
		}

		$qb = $this->db->getQueryBuilder();
		$qb->select('id', 'name', 'is_valid', 'is_visible')
		   ->from($this->getTableName())
		   ->where($qb->expr()->eq('id', $qb->createNamedParameter($personId)));
		return $this->findEntity($qb);
	}

	public function countClusterFaces(int $personId): int {
		$qb = $this->db->getQueryBuilder();
		$query = $qb
			->select($qb->createFunction('COUNT(' . $qb->getColumnName('id') . ')'))
			->from('facerecog_faces')
			->where($qb->expr()->eq('person', $qb->createParameter('person')))
			->setParameter('person', $personId);
		$resultStatement = $query->execute();
		$data = $resultStatement->fetch(\PDO::FETCH_NUM);
		$resultStatement->closeCursor();

		return (int)$data[0];
	}

	/**
	 * Updates one face with $faceId to database to person ID $personId.
	 *
	 * @param int $faceId ID of the face
	 * @param int|null $personId ID of the person
	 *
	 * @return void
	 */
	private function updateFace(int $faceId, $personId): void {
		$qb = $this->db->getQueryBuilder();
		$qb->update('facerecog_faces')
			->set("person", $qb->createNamedParameter($personId))
			->where($qb->expr()->eq('id', $qb->createNamedParameter($faceId)))
			->execute();
	}

	/**
	 * Checks if face with a given ID is in any cluster.
	 *
	 * @param int $faceId ID of the face to check
	 * @param array $cluster All clusters to check into
	 *
	 * @return bool True if face is found in any cluster, false otherwise.
	 */
	private function isFaceInClusters(int $faceId, array $clusters): bool {
		foreach ($clusters as $_=>$faces) {
			if (in_array($faceId, $faces)) {
				return true;
			}
		}
		return false;
	}
}
