<?php

/**
 * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
 * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
 * SPDX-License-Identifier: AGPL-3.0-only
 */
namespace OCA\User_LDAP\Mapping;

use Doctrine\DBAL\Exception;
use OCP\DB\IPreparedStatement;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IDBConnection;
use OCP\Server;
use Psr\Log\LoggerInterface;

/**
 * Class AbstractMapping
 *
 * @package OCA\User_LDAP\Mapping
 */
abstract class AbstractMapping {
	/**
	 * returns the DB table name which holds the mappings
	 *
	 * @return string
	 */
	abstract protected function getTableName(bool $includePrefix = true);

	/**
	 * A month worth of cache time for as good as never changing mapping data.
	 * Implemented when it was found that name-to-DN lookups are quite frequent.
	 */
	protected const LOCAL_CACHE_TTL = 2592000;

	/**
	 * A week worth of cache time for rarely changing user count data.
	 */
	protected const LOCAL_USER_COUNT_TTL = 604800;

	/**
	 * By default, the local cache is only used up to a certain amount of objects.
	 * This constant holds this number. The amount of entries would amount up to
	 * 1 MiB (worst case) per mappings table.
	 * Setting `use_local_mapping_cache` for `user_ldap` to `yes` or `no`
	 * deliberately enables or disables this mechanism.
	 */
	protected const LOCAL_CACHE_OBJECT_THRESHOLD = 2000;

	protected ?ICache $localNameToDnCache = null;

	/** @var array caches Names (value) by DN (key) */
	protected array $cache = [];

	/**
	 * @param IDBConnection $dbc
	 */
	public function __construct(
		protected IDBConnection $dbc,
		protected ICacheFactory $cacheFactory,
		protected IAppConfig $config,
		protected bool $isCLI,
	) {
		$this->initLocalCache();
	}

	protected function initLocalCache(): void {
		if ($this->isCLI || !$this->cacheFactory->isLocalCacheAvailable()) {
			return;
		}

		$useLocalCache = $this->config->getValueString('user_ldap', 'use_local_mapping_cache', 'auto', false);
		if ($useLocalCache !== 'yes' && $useLocalCache !== 'auto') {
			return;
		}

		$section = \str_contains($this->getTableName(), 'user') ? 'u/' : 'g/';
		$this->localNameToDnCache = $this->cacheFactory->createLocal('ldap/map/' . $section);

		// We use the cache as well to store whether it shall be used. If the
		// answer was no, we unset it again.
		if ($useLocalCache === 'auto' && !$this->useCacheByUserCount()) {
			$this->localNameToDnCache = null;
		}
	}

	protected function useCacheByUserCount(): bool {
		$use = $this->localNameToDnCache->get('use');
		if ($use !== null) {
			return $use;
		}

		$qb = $this->dbc->getQueryBuilder();
		$q = $qb->selectAlias($qb->createFunction('COUNT(owncloud_name)'), 'count')
			->from($this->getTableName());
		$q->setMaxResults(self::LOCAL_CACHE_OBJECT_THRESHOLD + 1);
		$result = $q->executeQuery();
		$row = $result->fetch();
		$result->closeCursor();

		$use = (int)$row['count'] <= self::LOCAL_CACHE_OBJECT_THRESHOLD;
		$this->localNameToDnCache->set('use', $use, self::LOCAL_USER_COUNT_TTL);
		return $use;
	}

	/**
	 * checks whether a provided string represents an existing table col
	 *
	 * @param string $col
	 * @return bool
	 */
	public function isColNameValid($col) {
		switch ($col) {
			case 'ldap_dn':
			case 'ldap_dn_hash':
			case 'owncloud_name':
			case 'directory_uuid':
				return true;
			default:
				return false;
		}
	}

	/**
	 * Gets the value of one column based on a provided value of another column
	 *
	 * @param string $fetchCol
	 * @param string $compareCol
	 * @param string $search
	 * @return string|false
	 * @throws \Exception
	 */
	protected function getXbyY($fetchCol, $compareCol, $search) {
		if (!$this->isColNameValid($fetchCol)) {
			//this is used internally only, but we don't want to risk
			//having SQL injection at all.
			throw new \Exception('Invalid Column Name');
		}
		$qb = $this->dbc->getQueryBuilder();
		$qb->select($fetchCol)
			->from($this->getTableName())
			->where($qb->expr()->eq($compareCol, $qb->createNamedParameter($search)));

		try {
			$res = $qb->executeQuery();
			$data = $res->fetchOne();
			$res->closeCursor();
			return $data;
		} catch (Exception $e) {
			return false;
		}
	}

	/**
	 * Performs a DELETE or UPDATE query to the database.
	 *
	 * @param IPreparedStatement $statement
	 * @param array $parameters
	 * @return bool true if at least one row was modified, false otherwise
	 */
	protected function modify(IPreparedStatement $statement, $parameters) {
		try {
			$result = $statement->execute($parameters);
			$updated = $result->rowCount() > 0;
			$result->closeCursor();
			return $updated;
		} catch (Exception $e) {
			return false;
		}
	}

	/**
	 * Gets the LDAP DN based on the provided name.
	 */
	public function getDNByName(string $name): string|false {
		$dn = array_search($name, $this->cache, true);
		if ($dn === false) {
			$dn = $this->localNameToDnCache?->get($name);
			if ($dn === null) {
				$dn = $this->getXbyY('ldap_dn', 'owncloud_name', $name);
				if ($dn !== false) {
					$this->cache[$dn] = $name;
				}
				$this->localNameToDnCache?->set($name, $dn, self::LOCAL_CACHE_TTL);
			}
		}
		return $dn ?? false;
	}

	/**
	 * Updates the DN based on the given UUID
	 *
	 * @param string $fdn
	 * @param string $uuid
	 * @return bool
	 */
	public function setDNbyUUID($fdn, $uuid) {
		$oldDn = $this->getDnByUUID($uuid);
		$statement = $this->dbc->prepare('
			UPDATE `' . $this->getTableName() . '`
			SET `ldap_dn_hash` = ?, `ldap_dn` = ?
			WHERE `directory_uuid` = ?
		');

		$r = $this->modify($statement, [$this->getDNHash($fdn), $fdn, $uuid]);
		if ($r) {
			if (is_string($oldDn) && isset($this->cache[$oldDn])) {
				$userId = $this->cache[$oldDn];
			}
			$userId = $userId ?? $this->getNameByUUID($uuid);
			if ($userId) {
				$this->cache[$fdn] = $userId;
				$this->localNameToDnCache?->set($userId, $fdn, self::LOCAL_CACHE_TTL);
			}
			unset($this->cache[$oldDn]);
		}

		return $r;
	}

	/**
	 * Updates the UUID based on the given DN
	 *
	 * required by Migration/UUIDFix
	 *
	 * @param $uuid
	 * @param $fdn
	 * @return bool
	 */
	public function setUUIDbyDN($uuid, $fdn): bool {
		$statement = $this->dbc->prepare('
			UPDATE `' . $this->getTableName() . '`
			SET `directory_uuid` = ?
			WHERE `ldap_dn_hash` = ?
		');

		unset($this->cache[$fdn]);

		return $this->modify($statement, [$uuid, $this->getDNHash($fdn)]);
	}

	/**
	 * Get the hash to store in database column ldap_dn_hash for a given dn
	 */
	protected function getDNHash(string $fdn): string {
		return hash('sha256', $fdn, false);
	}

	/**
	 * Gets the name based on the provided LDAP DN.
	 *
	 * @param string $fdn
	 * @return string|false
	 */
	public function getNameByDN($fdn) {
		if (!isset($this->cache[$fdn])) {
			$this->cache[$fdn] = $this->getXbyY('owncloud_name', 'ldap_dn_hash', $this->getDNHash($fdn));
		}
		return $this->cache[$fdn];
	}

	/**
	 * @param array<string> $hashList
	 */
	protected function prepareListOfIdsQuery(array $hashList): IQueryBuilder {
		$qb = $this->dbc->getQueryBuilder();
		$qb->select('owncloud_name', 'ldap_dn_hash', 'ldap_dn')
			->from($this->getTableName(false))
			->where($qb->expr()->in('ldap_dn_hash', $qb->createNamedParameter($hashList, IQueryBuilder::PARAM_STR_ARRAY)));
		return $qb;
	}

	protected function collectResultsFromListOfIdsQuery(IQueryBuilder $qb, array &$results): void {
		$stmt = $qb->executeQuery();
		while ($entry = $stmt->fetch(\Doctrine\DBAL\FetchMode::ASSOCIATIVE)) {
			$results[$entry['ldap_dn']] = $entry['owncloud_name'];
			$this->cache[$entry['ldap_dn']] = $entry['owncloud_name'];
		}
		$stmt->closeCursor();
	}

	/**
	 * @param array<string> $fdns
	 * @return array<string,string>
	 */
	public function getListOfIdsByDn(array $fdns): array {
		$totalDBParamLimit = 65000;
		$sliceSize = 1000;
		$maxSlices = $this->dbc->getDatabaseProvider() === IDBConnection::PLATFORM_SQLITE ? 9 : $totalDBParamLimit / $sliceSize;
		$results = [];

		$slice = 1;
		$fdns = array_map([$this, 'getDNHash'], $fdns);
		$fdnsSlice = count($fdns) > $sliceSize ? array_slice($fdns, 0, $sliceSize) : $fdns;
		$qb = $this->prepareListOfIdsQuery($fdnsSlice);

		while (isset($fdnsSlice[999])) {
			// Oracle does not allow more than 1000 values in the IN list,
			// but allows slicing
			$slice++;
			$fdnsSlice = array_slice($fdns, $sliceSize * ($slice - 1), $sliceSize);

			/** @see https://github.com/vimeo/psalm/issues/4995 */
			/** @psalm-suppress TypeDoesNotContainType */
			if (!isset($qb)) {
				$qb = $this->prepareListOfIdsQuery($fdnsSlice);
				continue;
			}

			if (!empty($fdnsSlice)) {
				$qb->orWhere($qb->expr()->in('ldap_dn_hash', $qb->createNamedParameter($fdnsSlice, IQueryBuilder::PARAM_STR_ARRAY)));
			}

			if ($slice % $maxSlices === 0) {
				$this->collectResultsFromListOfIdsQuery($qb, $results);
				unset($qb);
			}
		}

		if (isset($qb)) {
			$this->collectResultsFromListOfIdsQuery($qb, $results);
		}

		return $results;
	}

	/**
	 * Searches mapped names by the giving string in the name column
	 *
	 * @return string[]
	 */
	public function getNamesBySearch(string $search, string $prefixMatch = '', string $postfixMatch = ''): array {
		$statement = $this->dbc->prepare('
			SELECT `owncloud_name`
			FROM `' . $this->getTableName() . '`
			WHERE `owncloud_name` LIKE ?
		');

		try {
			$res = $statement->execute([$prefixMatch . $this->dbc->escapeLikeParameter($search) . $postfixMatch]);
		} catch (Exception $e) {
			return [];
		}
		$names = [];
		while ($row = $res->fetch()) {
			$names[] = $row['owncloud_name'];
		}
		return $names;
	}

	/**
	 * Gets the name based on the provided LDAP UUID.
	 *
	 * @param string $uuid
	 * @return string|false
	 */
	public function getNameByUUID($uuid) {
		return $this->getXbyY('owncloud_name', 'directory_uuid', $uuid);
	}

	public function getDnByUUID($uuid) {
		return $this->getXbyY('ldap_dn', 'directory_uuid', $uuid);
	}

	/**
	 * Gets the UUID based on the provided LDAP DN
	 *
	 * @param string $dn
	 * @return false|string
	 * @throws \Exception
	 */
	public function getUUIDByDN($dn) {
		return $this->getXbyY('directory_uuid', 'ldap_dn_hash', $this->getDNHash($dn));
	}

	public function getList(int $offset = 0, ?int $limit = null, bool $invalidatedOnly = false): array {
		$select = $this->dbc->getQueryBuilder();
		$select->selectAlias('ldap_dn', 'dn')
			->selectAlias('owncloud_name', 'name')
			->selectAlias('directory_uuid', 'uuid')
			->from($this->getTableName())
			->setMaxResults($limit)
			->setFirstResult($offset);

		if ($invalidatedOnly) {
			$select->where($select->expr()->like('directory_uuid', $select->createNamedParameter('invalidated_%')));
		}

		$result = $select->executeQuery();
		$entries = $result->fetchAll();
		$result->closeCursor();

		return $entries;
	}

	/**
	 * attempts to map the given entry
	 *
	 * @param string $fdn fully distinguished name (from LDAP)
	 * @param string $name
	 * @param string $uuid a unique identifier as used in LDAP
	 * @return bool
	 */
	public function map($fdn, $name, $uuid) {
		if (mb_strlen($fdn) > 4000) {
			Server::get(LoggerInterface::class)->error(
				'Cannot map, because the DN exceeds 4000 characters: {dn}',
				[
					'app' => 'user_ldap',
					'dn' => $fdn,
				]
			);
			return false;
		}

		$row = [
			'ldap_dn_hash' => $this->getDNHash($fdn),
			'ldap_dn' => $fdn,
			'owncloud_name' => $name,
			'directory_uuid' => $uuid
		];

		try {
			$result = $this->dbc->insertIfNotExist($this->getTableName(), $row);
			if ((bool)$result === true) {
				$this->cache[$fdn] = $name;
				$this->localNameToDnCache?->set($name, $fdn, self::LOCAL_CACHE_TTL);
			}
			// insertIfNotExist returns values as int
			return (bool)$result;
		} catch (\Exception $e) {
			return false;
		}
	}

	/**
	 * removes a mapping based on the owncloud_name of the entry
	 *
	 * @param string $name
	 * @return bool
	 */
	public function unmap($name) {
		$statement = $this->dbc->prepare('
			DELETE FROM `' . $this->getTableName() . '`
			WHERE `owncloud_name` = ?');

		$dn = array_search($name, $this->cache);
		if ($dn !== false) {
			unset($this->cache[$dn]);
		}
		$this->localNameToDnCache?->remove($name);

		return $this->modify($statement, [$name]);
	}

	/**
	 * Truncates the mapping table
	 *
	 * @return bool
	 */
	public function clear() {
		$sql = $this->dbc
			->getDatabasePlatform()
			->getTruncateTableSQL('`' . $this->getTableName() . '`');
		try {
			$this->dbc->executeQuery($sql);
			$this->localNameToDnCache?->clear();

			return true;
		} catch (Exception $e) {
			return false;
		}
	}

	/**
	 * clears the mapping table one by one and executing a callback with
	 * each row's id (=owncloud_name col)
	 *
	 * @param callable $preCallback
	 * @param callable $postCallback
	 * @return bool true on success, false when at least one row was not
	 *              deleted
	 */
	public function clearCb(callable $preCallback, callable $postCallback): bool {
		$picker = $this->dbc->getQueryBuilder();
		$picker->select('owncloud_name')
			->from($this->getTableName());
		$cursor = $picker->executeQuery();
		$result = true;
		while (($id = $cursor->fetchOne()) !== false) {
			$preCallback($id);
			if ($isUnmapped = $this->unmap($id)) {
				$postCallback($id);
			}
			$result = $result && $isUnmapped;
		}
		$cursor->closeCursor();
		return $result;
	}

	/**
	 * returns the number of entries in the mappings table
	 *
	 * @return int
	 */
	public function count(): int {
		$query = $this->dbc->getQueryBuilder();
		$query->select($query->func()->count('ldap_dn_hash'))
			->from($this->getTableName());
		$res = $query->executeQuery();
		$count = $res->fetchOne();
		$res->closeCursor();
		return (int)$count;
	}

	public function countInvalidated(): int {
		$query = $this->dbc->getQueryBuilder();
		$query->select($query->func()->count('ldap_dn_hash'))
			->from($this->getTableName())
			->where($query->expr()->like('directory_uuid', $query->createNamedParameter('invalidated_%')));
		$res = $query->executeQuery();
		$count = $res->fetchOne();
		$res->closeCursor();
		return (int)$count;
	}
}
