<?php
/**
* @version	$Id: deployment_helper.php 14402 2011-06-28 15:22:48Z alex $
* @package	In-Portal
* @copyright	Copyright (C) 1997 - 2011 Intechnic. All rights reserved.
* @license      GNU/GPL
* In-Portal is Open Source software.
* This means that this software may have been modified pursuant
* the GNU General Public License, and as distributed it includes
* or is derivative of works licensed under the GNU General Public License
* or other free or open source software licenses.
* See http://www.in-portal.org/license for copyright notices and details.
*/

defined('FULL_PATH') or die('restricted access!');

class DeploymentHelper extends kHelper {

	private $moduleName = '';

	private $revisionSqls = Array ();

	private $revisionDependencies = Array ();

	private $appliedRevisions = Array ();

	private $dryRun = false;

	private $lineEnding = PHP_EOL;

	public function DeploymentHelper()
	{
		parent::kHelper();

		set_time_limit(0);
		ini_set('memory_limit', -1);
	}

	private function loadAppliedRevisions()
	{
		$sql = 'SELECT AppliedDBRevisions
				FROM ' . TABLE_PREFIX . 'Modules
				WHERE Name = ' . $this->Conn->qstr( $this->moduleName );
		$revisions = $this->Conn->GetOne($sql);

		$this->appliedRevisions = $revisions ? explode(',', $revisions) : Array ();
	}

	private function saveAppliedRevisions()
	{
		// maybe optimize
		sort($this->appliedRevisions);

		$fields_hash = Array (
			'AppliedDBRevisions' => implode(',', $this->appliedRevisions),
		);

		$this->Conn->doUpdate($fields_hash, TABLE_PREFIX . 'Modules', '`Name` = ' . $this->Conn->qstr($this->moduleName));
	}

	public function deployAll($dry_run = false)
	{
		$this->dryRun = $dry_run;
		$this->lineEnding = $dry_run ? '<br/>' . PHP_EOL : PHP_EOL;

		$ret = true;

		foreach ($this->Application->ModuleInfo as $module_name => $module_info) {
			$this->moduleName = $module_name;

			if ( !file_exists($this->getModuleFile('project_upgrades.sql')) ) {
				continue;
			}

			$ret = $ret && $this->deploy($module_name);
		}

		if (!$this->dryRun) {
			$this->resetCaches();
			$this->refreshThemes();
		}

		return $ret;
	}

	/**
	 * Deploys pending changes to a site
	 *
	 */
	private function deploy($module_name)
	{
		echo 'Deploying Module "' . $module_name . '":' . $this->lineEnding;

		echo 'Upgrading Database ... ';
		if ( !$this->upgradeDatabase() ) {
			return false;
		}

		echo 'OK' . $this->lineEnding;

		$this->importLanguagePack();

		echo 'Done.' . $this->lineEnding;

		return true;
	}

	/**
	 * Import latest languagepack (without overwrite)
	 *
	 */
	private function importLanguagePack()
	{
		$language_import_helper =& $this->Application->recallObject('LanguageImportHelper');
		/* @var $language_import_helper LanguageImportHelper */

		echo 'Importing LanguagePack ... ';
		$filename = $this->getModuleFile('english.lang');
		$language_import_helper->performImport($filename, '|0|1|2|', $this->moduleName, LANG_SKIP_EXISTING);
		echo 'OK' . $this->lineEnding;
	}

	/**
	 * Resets unit and section cache
	 *
	 */
	private function resetCaches()
	{
		// 2. reset unit config cache (so new classes get auto-registered)
		echo 'Resetting Unit Config Cache ... ';
		$admin_event = new kEvent('adm:OnResetConfigsCache');
		$this->Application->HandleEvent($admin_event);
		echo 'OK' . $this->lineEnding;

		// 3. reset sections cache
		echo 'Resetting Sections Cache ... ';
		$admin_event = new kEvent('adm:OnResetSections');
		$this->Application->HandleEvent($admin_event);
		echo 'OK' . $this->lineEnding;
	}

	/**
	 * Rebuild theme files
	 *
	 */
	private function refreshThemes()
	{
		echo 'Rebuilding Theme Files ... ';
		$admin_event = new kEvent('adm:OnRebuildThemes');
		$this->Application->HandleEvent($admin_event);
		echo 'OK' . $this->lineEnding;
	}

	/**
	 * Runs database upgrade script
	 *
	 * @return bool
	 */
	private function upgradeDatabase()
	{
		$this->loadAppliedRevisions();
		$this->Conn->errorHandler = Array(&$this, 'handleSqlError');

		if ( !$this->collectDatabaseRevisions() || !$this->checkRevisionDependencies() ) {
			return false;
		}

		$applied = $this->applyRevisions();
		$this->saveAppliedRevisions();

		return $applied;
	}

	/**
	 * Collects database revisions from "project_upgrades.sql" file.
	 *
	 * @return bool
	 */
	private function collectDatabaseRevisions()
	{
		$filename = $this->getModuleFile('project_upgrades.sql');

		if ( !file_exists($filename) ) {
			return true;
		}

		$sqls = file_get_contents($filename);
		preg_match_all("/# r([\d]+)([^\:]*):.*?(\n|$)/s", $sqls, $matches, PREG_SET_ORDER + PREG_OFFSET_CAPTURE);

		if (!$matches) {
			echo 'No Database Revisions Found' . $this->lineEnding;

			return false;
		}

		foreach ($matches as $index => $match) {
			$revision = $match[1][0];

			if ( $this->revisionApplied($revision) ) {
				// skip applied revisions
				continue;
			}

			if ( isset($this->revisionSqls[$revision]) ) {
				// duplicate revision among non-applied ones
				echo 'Duplicate revision ' . $revision . ' found' . $this->lineEnding;

				return false;
			}

			// get revision sqls
			$start_pos = $match[0][1] + strlen($match[0][0]);
			$end_pos = isset($matches[$index + 1]) ? $matches[$index + 1][0][1] : strlen($sqls);
			$revision_sqls = substr($sqls, $start_pos, $end_pos - $start_pos);

			if (!$revision_sqls) {
				// resision without sqls
				continue;
			}

			$this->revisionSqls[$revision] = $revision_sqls;
			$revision_lependencies = $this->parseRevisionDependencies($match[2][0]);

			if ($revision_lependencies) {
				$this->revisionDependencies[$revision] = $revision_lependencies;
			}
		}

		ksort($this->revisionSqls);
		ksort($this->revisionDependencies);

		return true;
	}

	/**
	 * Checks that all dependent revisions are either present now OR were applied before
	 *
	 * @return bool
	 */
	private function checkRevisionDependencies()
	{
		foreach ($this->revisionDependencies as $revision => $revision_dependencies) {
			foreach ($revision_dependencies as $revision_dependency) {
				if ( $this->revisionApplied($revision_dependency) ) {
					// revision dependend upon already applied -> depencency fulfilled
					continue;
				}

				if ($revision_dependency >= $revision) {
					echo 'Revision ' . $revision . ' has incorrect dependency to revision ' . $revision_dependency . '. Only dependencies to older revisions are allowed!' . $this->lineEnding;

					return false;
				}

				if ( !isset($this->revisionSqls[$revision_dependency]) ) {
					echo 'Revision ' . $revision . ' depends on missing revision ' . $revision_dependency . '!' . $this->lineEnding;

					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Runs all pending sqls
	 *
	 * @return bool
	 */
	private function applyRevisions()
	{
		if (!$this->revisionSqls) {
			return true;
		}

		if ($this->dryRun) {
			$this->appliedRevisions = array_merge($this->appliedRevisions, array_keys($this->revisionSqls));

			return true;
		}

		echo $this->lineEnding;

		foreach ($this->revisionSqls as $revision => $sqls) {
			echo 'Processing DB Revision: #' . $revision . ' ... ';

			$sqls = str_replace("\r\n", "\n", $sqls);  // convert to linux line endings
			$no_comment_sqls = preg_replace("/#\s([^;]*?)\n/is", '', $sqls); // remove all comments "#" on new lines

			$sqls = explode(";\n", $no_comment_sqls . "\n"); // ensures that last sql won't have ";" in it
			$sqls = array_map('trim', $sqls);

			foreach ($sqls as $index => $sql) {
				if (!$sql || (substr($sql, 0, 1) == '#')) {
					continue; // usually last line
				}

				$this->Conn->Query($sql);

				if ( $this->Conn->hasError() ) {
					// consider revisions with errors applied
					$this->appliedRevisions[] = $revision;

	  				return false;
	    		}
			}

			$this->appliedRevisions[] = $revision;
			echo 'OK' . $this->lineEnding;
		}

		return true;
	}

	/**
	 * Error handler for sql errors
	 *
	 * @param int $code
	 * @param string $msg
	 * @param string $sql
	 * @return bool
	 */
	public function handleSqlError($code, $msg, $sql)
	{
		echo 'Error (#' . $code . ': ' . $msg . ') during SQL processing:' . $this->lineEnding . $sql . $this->lineEnding;
		echo 'Please execute rest of sqls in this revision by hand and run deployment script again.' . $this->lineEnding;

		return true;
	}

	/**
	 * Checks if given revision was already applied
	 *
	 * @param int $revision
	 * @return bool
	 */
	private function revisionApplied($revision)
	{
		foreach ($this->appliedRevisions as $applied_revision) {
			// revision range
			$applied_revision = explode('-', $applied_revision, 2);

			if ( !isset($applied_revision[1]) ) {
				// convert single revision to revision range
				$applied_revision[1] = $applied_revision[0];
			}

			if ( $revision >= $applied_revision[0] && $revision <= $applied_revision[1] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Returns path to given file in current module install folder
	 *
	 * @param string $filename
	 * @return string
	 */
	private function getModuleFile($filename)
	{
		$module_folder = $this->Application->findModule('Name', $this->moduleName, 'Path');

		return FULL_PATH . DIRECTORY_SEPARATOR . $module_folder . 'install/' . $filename;
	}

	/**
	 * Extracts revisions from string in format "(1,3,5464,23342,3243)"
	 *
	 * @param string $string
	 * @return Array
	 */
	private function parseRevisionDependencies($string)
	{
		if (!$string) {
			return Array ();
		}

		$string = explode(',', substr($string, 1, -1));

		return array_map('trim', $string);
	}
}