<?php
/**
 * @author Jörn Friedrich Dreyer <jfd@butonic.de>
 * @author Vincent Petry <pvince81@owncloud.com>
 *
 * @copyright Copyright (c) 2018, ownCloud GmbH
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, version 3,
 * as published by the Free Software Foundation.
 *
 * 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, version 3,
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
 *
 */

namespace OC\Repair;

use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use OC\Share\Constants;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\IUser;
use OCP\IGroupManager;
use OC\Share20\DefaultShareProvider;

/**
 * Repairs shares for which the received folder was not properly deduplicated.
 *
 * An unmerged share can for example happen when sharing a folder with the same
 * user through multiple ways, like several groups and also directly, additionally
 * to group shares. Since 9.0.0 these would create duplicate entries "folder (2)",
 * one for every share. This repair step rearranges them so they only appear as a single
 * folder.
 */
class RepairUnmergedShares implements IRepairStep {

	/** @var \OCP\IConfig */
	protected $config;

	/** @var \OCP\IDBConnection */
	protected $connection;

	/** @var IUserManager */
	protected $userManager;

	/** @var IGroupManager */
	protected $groupManager;

	/** @var IQueryBuilder */
	private $queryGetSharesWithUsers;

	/** @var IQueryBuilder */
	private $queryUpdateSharePermissionsAndTarget;

	/** @var IQueryBuilder */
	private $queryUpdateShareInBatch;

	/**
	 * @param \OCP\IConfig $config
	 * @param \OCP\IDBConnection $connection
	 */
	public function __construct(
		IConfig $config,
		IDBConnection $connection,
		IUserManager $userManager,
		IGroupManager $groupManager
	) {
		$this->connection = $connection;
		$this->config = $config;
		$this->userManager = $userManager;
		$this->groupManager = $groupManager;
	}

	public function getName() {
		return 'Repair unmerged shares';
	}

	/**
	 * Builds prepared queries for reuse
	 */
	private function buildPreparedQueries() {
		/**
		 * Retrieve shares for a given user/group and share type
		 */
		$query = $this->connection->getQueryBuilder();
		$query
			->select('item_source', 'id', 'file_target', 'permissions', 'parent', 'share_type', 'stime')
			->from('share')
			->where($query->expr()->eq('share_type', $query->createParameter('shareType')))
			->andWhere($query->expr()->in('share_with', $query->createParameter('shareWiths')))
			->andWhere($query->expr()->in('item_type', $query->createParameter('itemTypes')))
			->orderBy('item_source', 'ASC')
			->addOrderBy('stime', 'ASC');

		$this->queryGetSharesWithUsers = $query;

		/**
		 * Updates the file_target to the given value for all given share ids.
		 *
		 * This updates several shares in bulk which is faster than individually.
		 */
		$query = $this->connection->getQueryBuilder();
		$query->update('share')
			->set('file_target', $query->createParameter('file_target'))
			->where($query->expr()->in('id', $query->createParameter('ids')));

		$this->queryUpdateShareInBatch = $query;

		/**
		 * Updates the share permissions and target path of a single share.
		 */
		$query = $this->connection->getQueryBuilder();
		$query->update('share')
			->set('permissions', $query->createParameter('permissions'))
			->set('file_target', $query->createParameter('file_target'))
			->where($query->expr()->eq('id', $query->createParameter('shareid')));

		$this->queryUpdateSharePermissionsAndTarget = $query;
	}

	private function getSharesWithUser($shareType, $shareWiths) {
		$groupedShares = [];

		$query = $this->queryGetSharesWithUsers;
		$query->setParameter('shareWiths', $shareWiths, IQueryBuilder::PARAM_STR_ARRAY);
		$query->setParameter('shareType', $shareType);
		$query->setParameter('itemTypes', ['file', 'folder'], IQueryBuilder::PARAM_STR_ARRAY);

		$shares = $query->execute()->fetchAll();

		// group by item_source
		foreach ($shares as $share) {
			if (!isset($groupedShares[$share['item_source']])) {
				$groupedShares[$share['item_source']] = [];
			}
			$groupedShares[$share['item_source']][] = $share;
		}
		return $groupedShares;
	}

	private function isPotentialDuplicateName($name) {
		return (\preg_match('/\(\d+\)(\.[^\.]+)?$/', $name) === 1);
	}

	/**
	 * Decide on the best target name based on all group shares and subshares,
	 * goal is to increase the likeliness that the chosen name matches what
	 * the user is expecting.
	 *
	 * For this, we discard the entries with parenthesis "(2)".
	 * In case the user also renamed the duplicates to a legitimate name, this logic
	 * will still pick the most recent one as it's the one the user is most likely to
	 * remember renaming.
	 *
	 * If no suitable subshare is found, use the least recent group share instead.
	 *
	 * @param array $groupShares group share entries
	 * @param array $subShares sub share entries
	 *
	 * @return string chosen target name
	 */
	private function findBestTargetName($groupShares, $subShares) {
		$pickedShare = null;
		// sort by stime, this also properly sorts the direct user share if any
		@\usort($subShares, function ($a, $b) {
			return ((int)$a['stime'] - (int)$b['stime']);
		});

		foreach ($subShares as $subShare) {
			// skip entries that have parenthesis with numbers
			if ($this->isPotentialDuplicateName($subShare['file_target'])) {
				continue;
			}
			// pick any share found that would match, the last being the most recent
			$pickedShare = $subShare;
		}

		// no suitable subshare found
		if ($pickedShare === null) {
			// use least recent group share target instead
			$pickedShare = $groupShares[0];
		}

		return $pickedShare['file_target'];
	}

	/**
	 * Fix the given received share represented by the set of group shares
	 * and matching sub shares
	 *
	 * @param array $groupShares group share entries
	 * @param array $subShares sub share entries
	 *
	 * @return boolean false if the share was not repaired, true if it was
	 */
	private function fixThisShare($groupShares, $subShares) {
		if (empty($subShares)) {
			return false;
		}

		$groupSharesById = [];
		foreach ($groupShares as $groupShare) {
			$groupSharesById[$groupShare['id']] = $groupShare;
		}

		if ($this->isThisShareValid($groupSharesById, $subShares)) {
			return false;
		}

		$targetPath = $this->findBestTargetName($groupShares, $subShares);

		// check whether the user opted out completely of all subshares
		$optedOut = true;
		foreach ($subShares as $subShare) {
			if ((int)$subShare['permissions'] !== 0) {
				$optedOut = false;
				break;
			}
		}

		$shareIds = [];
		foreach ($subShares as $subShare) {
			// only if the user deleted some subshares but not all, adjust the permissions of that subshare
			if (!$optedOut && (int)$subShare['permissions'] === 0 && (int)$subShare['share_type'] === DefaultShareProvider::SHARE_TYPE_USERGROUP) {
				// set permissions from parent group share
				$permissions = $groupSharesById[$subShare['parent']]['permissions'];

				// fix permissions and target directly
				$query = $this->queryUpdateSharePermissionsAndTarget;
				$query->setParameter('shareid', $subShare['id']);
				$query->setParameter('file_target', $targetPath);
				$query->setParameter('permissions', $permissions);
				$query->execute();
			} else {
				// gather share ids for bulk target update
				if ($subShare['file_target'] !== $targetPath) {
					$shareIds[] = (int)$subShare['id'];
				}
			}
		}

		if (!empty($shareIds)) {
			$query = $this->queryUpdateShareInBatch;
			$query->setParameter('ids', $shareIds, IQueryBuilder::PARAM_INT_ARRAY);
			$query->setParameter('file_target', $targetPath);
			$query->execute();
		}

		return true;
	}

	/**
	 * Checks whether the number of group shares is balanced with the child subshares.
	 * If all group shares have exactly one subshare, and the target of every subshare
	 * is the same, then the share is valid.
	 * If however there is a group share entry that has no matching subshare, it means
	 * we're in the bogus situation and the whole share must be repaired
	 *
	 * @param array $groupSharesById
	 * @param array $subShares
	 *
	 * @return true if the share is valid, false if it needs repair
	 */
	private function isThisShareValid($groupSharesById, $subShares) {
		$foundTargets = [];

		// every group share needs to have exactly one matching subshare
		foreach ($subShares as $subShare) {
			$foundTargets[$subShare['file_target']] = true;
			if (\count($foundTargets) > 1) {
				// not all the same target path value => invalid
				return false;
			}
			if (isset($groupSharesById[$subShare['parent']])) {
				// remove it from the list as we found it
				unset($groupSharesById[$subShare['parent']]);
			}
		}

		// if we found one subshare per group entry, the set will be empty.
		// If not empty, it means that one of the group shares did not have
		// a matching subshare entry.
		return empty($groupSharesById);
	}

	/**
	 * Detect unmerged received shares and merge them properly
	 */
	private function fixUnmergedShares(IOutput $out, IUser $user) {
		$groups = $this->groupManager->getUserGroupIds($user);
		if (empty($groups)) {
			// user is in no groups, so can't have received group shares
			return;
		}

		// get all subshares grouped by item source
		$subSharesByItemSource = $this->getSharesWithUser(DefaultShareProvider::SHARE_TYPE_USERGROUP, [$user->getUID()]);

		// because sometimes one wants to give the user more permissions than the group share
		$userSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_USER, [$user->getUID()]);

		if (empty($subSharesByItemSource) && empty($userSharesByItemSource)) {
			// nothing to repair for this user, no need to do extra queries
			return;
		}

		$groupSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_GROUP, $groups);
		if (empty($groupSharesByItemSource) && empty($userSharesByItemSource)) {
			// nothing to repair for this user
			return;
		}

		foreach ($groupSharesByItemSource as $itemSource => $groupShares) {
			$subShares = [];
			if (isset($subSharesByItemSource[$itemSource])) {
				$subShares = $subSharesByItemSource[$itemSource];
			}

			if (isset($userSharesByItemSource[$itemSource])) {
				// add it to the subshares to get a similar treatment
				$subShares = \array_merge($subShares, $userSharesByItemSource[$itemSource]);
			}

			$this->fixThisShare($groupShares, $subShares);
		}
	}

	public function run(IOutput $output) {
		$ocVersionFromBeforeUpdate = $this->config->getSystemValue('version', '0.0.0');
		// this situation was only possible between 9.0.0 and 9.0.3 included, and 9.1.0
		if (
			\version_compare($ocVersionFromBeforeUpdate, '9.1.0', '>=')
			&& \version_compare($ocVersionFromBeforeUpdate, '9.1.0.16', '<')
			) {
			$function = function (IUser $user) use ($output) {
				$this->fixUnmergedShares($output, $user);
				$output->advance();
			};

			$this->buildPreparedQueries();

			$output->startProgress($this->userManager->countUsers());

			$this->userManager->callForAllUsers($function);

			$output->finishProgress();
		}
	}
}
