<?php
/**
 * @author infoneo <infoneo@yahoo.pl>
 * @author Joas Schilling <nickvergessen@gmx.de>
 * @author Jörn Friedrich Dreyer <jfd@butonic.de>
 * @author Lukas Reschke <lukas@owncloud.com>
 * @author Robin McCorkell <rmccorkell@karoshi.org.uk>
 * @author Thomas Müller <thomas.mueller@tmit.eu>
 *
 * @copyright Copyright (c) 2015, ownCloud, Inc.
 * @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\Files;

/**
 * class Mapper is responsible to translate logical paths to physical paths and reverse
 */
class Mapper
{
	private $unchangedPhysicalRoot;

	public function __construct($rootDir) {
		$this->unchangedPhysicalRoot = $rootDir;
	}

	/**
	 * @param string $logicPath
	 * @param bool $create indicates if the generated physical name shall be stored in the database or not
	 * @return string the physical path
	 */
	public function logicToPhysical($logicPath, $create) {
		$physicalPath = $this->resolveLogicPath($logicPath);
		if ($physicalPath !== null) {
			return $physicalPath;
		}

		return $this->create($logicPath, $create);
	}

	/**
	 * @param string $physicalPath
	 * @return string
	 */
	public function physicalToLogic($physicalPath) {
		$logicPath = $this->resolvePhysicalPath($physicalPath);
		if ($logicPath !== null) {
			return $logicPath;
		}

		$this->insert($physicalPath, $physicalPath);
		return $physicalPath;
	}

	/**
	 * @param string $path
	 * @param bool $isLogicPath indicates if $path is logical or physical
	 * @param boolean $recursive
	 * @return void
	 */
	public function removePath($path, $isLogicPath, $recursive) {
		if ($recursive) {
			$path=$path.'%';
		}

		if ($isLogicPath) {
			\OC_DB::executeAudited('DELETE FROM `*PREFIX*file_map` WHERE `logic_path` LIKE ?', array($path));
		} else {
			\OC_DB::executeAudited('DELETE FROM `*PREFIX*file_map` WHERE `physic_path` LIKE ?', array($path));
		}
	}

	/**
	 * @param string $path1
	 * @param string $path2
	 * @throws \Exception
	 */
	public function copy($path1, $path2)
	{
		$path1 = $this->resolveRelativePath($path1);
		$path2 = $this->resolveRelativePath($path2);
		$physicPath1 = $this->logicToPhysical($path1, true);
		$physicPath2 = $this->logicToPhysical($path2, true);

		$sql = 'SELECT * FROM `*PREFIX*file_map` WHERE `logic_path` LIKE ?';
		$result = \OC_DB::executeAudited($sql, array($path1.'%'));
		$updateQuery = \OC_DB::prepare('UPDATE `*PREFIX*file_map`'
			.' SET `logic_path` = ?'
			.' , `logic_path_hash` = ?'
			.' , `physic_path` = ?'
			.' , `physic_path_hash` = ?'
			.' WHERE `logic_path` = ?');
		while( $row = $result->fetchRow()) {
			$currentLogic = $row['logic_path'];
			$currentPhysic = $row['physic_path'];
			$newLogic = $path2.$this->stripRootFolder($currentLogic, $path1);
			$newPhysic = $physicPath2.$this->stripRootFolder($currentPhysic, $physicPath1);
			if ($path1 !== $currentLogic) {
				try {
					\OC_DB::executeAudited($updateQuery, array($newLogic, md5($newLogic), $newPhysic, md5($newPhysic),
						$currentLogic));
				} catch (\Exception $e) {
					error_log('Mapper::Copy failed '.$currentLogic.' -> '.$newLogic.'\n'.$e);
					throw $e;
				}
			}
		}
	}

	/**
	 * @param string $path
	 * @param string $root
	 * @return false|string
	 */
	public function stripRootFolder($path, $root) {
		if (strpos($path, $root) !== 0) {
			// throw exception ???
			return false;
		}
		if (strlen($path) > strlen($root)) {
			return substr($path, strlen($root));
		}

		return '';
	}

	/**
	 * @param string $logicPath
	 * @return null
	 * @throws \OC\DatabaseException
	 */
	private function resolveLogicPath($logicPath) {
		$logicPath = $this->resolveRelativePath($logicPath);
		$sql = 'SELECT * FROM `*PREFIX*file_map` WHERE `logic_path_hash` = ?';
		$result = \OC_DB::executeAudited($sql, array(md5($logicPath)));
		$result = $result->fetchRow();
		if ($result === false) {
			return null;
		}

		return $result['physic_path'];
	}

	private function resolvePhysicalPath($physicalPath) {
		$physicalPath = $this->resolveRelativePath($physicalPath);
		$sql = \OC_DB::prepare('SELECT * FROM `*PREFIX*file_map` WHERE `physic_path_hash` = ?');
		$result = \OC_DB::executeAudited($sql, array(md5($physicalPath)));
		$result = $result->fetchRow();

		return $result['logic_path'];
	}

	private function resolveRelativePath($path) {
		$explodedPath = explode('/', $path);
		$pathArray = array();
		foreach ($explodedPath as $pathElement) {
			if (empty($pathElement) || ($pathElement == '.')) {
				continue;
			} elseif ($pathElement == '..') {
				if (count($pathArray) == 0) {
					return false;
				}
				array_pop($pathArray);
			} else {
				array_push($pathArray, $pathElement);
			}
		}
		if (substr($path, 0, 1) == '/') {
			$path = '/';
		} else {
			$path = '';
		}
		return $path.implode('/', $pathArray);
	}

	/**
	 * @param string $logicPath
	 * @param bool $store
	 * @return string
	 */
	private function create($logicPath, $store) {
		$logicPath = $this->resolveRelativePath($logicPath);
		$index = 0;

		// create the slugified path
		$physicalPath = $this->slugifyPath($logicPath);

		// detect duplicates
		while ($this->resolvePhysicalPath($physicalPath) !== null) {
			$physicalPath = $this->slugifyPath($logicPath, $index++);
		}

		// insert the new path mapping if requested
		if ($store) {
			$this->insert($logicPath, $physicalPath);
		}

		return $physicalPath;
	}

	private function insert($logicPath, $physicalPath) {
		$sql = 'INSERT INTO `*PREFIX*file_map` (`logic_path`, `physic_path`, `logic_path_hash`, `physic_path_hash`)
				VALUES (?, ?, ?, ?)';
		\OC_DB::executeAudited($sql, array($logicPath, $physicalPath, md5($logicPath), md5($physicalPath)));
	}

	/**
	 * @param string $path
	 * @param int $index
	 * @return string
	 */
	public function slugifyPath($path, $index = null) {
		$path = $this->stripRootFolder($path, $this->unchangedPhysicalRoot);

		$pathElements = explode('/', $path);
		$sluggedElements = array();

		foreach ($pathElements as $pathElement) {
			// remove empty elements
			if (empty($pathElement)) {
				continue;
			}

			$sluggedElements[] = $this->slugify($pathElement);
		}

		// apply index to file name
		if ($index !== null) {
			$last = array_pop($sluggedElements);
			
			// if filename contains periods - add index number before last period
			if (preg_match('~\.[^\.]+$~i', $last, $extension)) {
				array_push($sluggedElements, substr($last, 0, -(strlen($extension[0]))) . '-' . $index . $extension[0]);
			} else {
				// if filename doesn't contain periods add index ofter the last char
				array_push($sluggedElements, $last . '-' . $index);
			}
		}

		$sluggedPath = $this->unchangedPhysicalRoot.implode('/', $sluggedElements);
		return $this->resolveRelativePath($sluggedPath);
	}

	/**
	 * Modifies a string to remove all non ASCII characters and spaces.
	 *
	 * @param string $text
	 * @return string
	 */
	private function slugify($text) {
		$originalText = $text;
		// replace non letter or digits or dots by -
		$text = preg_replace('~[^\\pL\d\.]+~u', '-', $text);

		// trim
		$text = trim($text, '-');

		// transliterate
		if (function_exists('iconv')) {
			$text = iconv('utf-8', 'us-ascii//TRANSLIT//IGNORE', $text);
		}

		// lowercase
		$text = strtolower($text);

		// remove unwanted characters
		$text = preg_replace('~[^-\w\.]+~', '', $text);
		
		// trim ending dots (for security reasons and win compatibility)
		$text = preg_replace('~\.+$~', '', $text);

		if (empty($text) || \OC\Files\Filesystem::isFileBlacklisted($text)) {
			/**
			 * Item slug would be empty. Previously we used uniqid() here.
			 * However this means that the behaviour is not reproducible, so
			 * when uploading files into a "empty" folder, the folders name is
			 * different.
			 *
			 * The other case is, that the slugified name would be a blacklisted
			 * filename. In this case we just use the same workaround by
			 * returning the secure md5 hash of the original name.
			 *
			 *
			 * If there would be a md5() hash collision, the deduplicate check
			 * will spot this and append an index later, so this should not be
			 * a problem.
			 */
			return md5($originalText);
		}

		return $text;
	}
}