<?php
/**
* @version	$Id: deployment_helper.php 16588 2017-07-19 07:36:53Z 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 {

	/**
	 * How many symbols from sql should be shown
	 */
	const SQL_TRIM_LENGTH = 120;

	/**
	 * Name of module, that is processed right now
	 *
	 * @var string
	 * @access private
	 */
	private $moduleName = '';

	/**
	 * List of sqls, associated with each revision (from project_upgrades.sql file)
	 *
	 * @var Array
	 * @access private
	 */
	private $revisionSqls = Array ();

	/**
	 * List of revision titles as user typed them (from project_upgrades.sql file)
	 * @var Array
	 */
	private $revisionTitles = Array ();

	/**
	 * Revision dependencies
	 *
	 * @var Array
	 * @access private
	 */
	private $revisionDependencies = Array ();

	/**
	 * Numbers of revisions, that were already applied
	 *
	 * @var Array
	 * @access private
	 */
	private $appliedRevisions = Array ();

	/**
	 * Don't change database, but only check syntax of project_upgrades.sql file and mark all revisions discovered as applied
	 *
	 * @var bool
	 * @access private
	 */
	private $dryRun = false;

	/**
	 * Remembers script invocation method
	 *
	 * @var bool
	 * @access public
	 */
	public $isCommandLine = false;

	/**
	 * IP Address of script invoker
	 *
	 * @var string
	 */
	public $ip = '';

	/**
	 * Event, that triggered deployment
	 *
	 * @var kEvent
	 * @access private
	 */
	private $_event;

	public function __construct()
	{
		parent::__construct();

		kUtil::setResourceLimit();

		$this->_event = new kEvent('adm:OnDummy');
		$this->isCommandLine = isset($GLOBALS['argv']) && count($GLOBALS['argv']);

		if ( !$this->isCommandLine ) {
			$this->ip = $this->Application->getClientIp();
		}
		elseif ( isset($GLOBALS['argv'][3]) ) {
			$this->ip = $GLOBALS['argv'][3];
		}
	}

	/**
	 * Sets event, associated with deployment
	 *
	 * @param kEvent $event
	 * @return void
	 * @access public
	 */
	public function setEvent(kEvent $event)
	{
		$this->_event = $event;
	}

	/**
	 * Adds message to script execution log
	 *
	 * @param string $message
	 * @param bool $new_line
	 * @return void
	 * @access private
	 */
	private function toLog($message, $new_line = true)
	{
		$log_file = (defined('RESTRICTED') ? RESTRICTED : WRITEABLE) . '/project_upgrades.log';

		$fp = fopen($log_file, 'a');
		fwrite($fp, $message . ($new_line ? "\n" : ''));
		fclose($fp);

		chmod($log_file, 0666);
	}

	/**
	 * Loads already applied revisions list of current module
	 *
	 * @return void
	 * @access private
	 */
	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 ();
	}

	/**
	 * Saves applied revision numbers to current module record
	 *
	 * @return void
	 * @access private
	 */
	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));
	}

	/**
	 * Deploys changes from all installed modules
	 *
	 * @param bool $dry_run
	 * @return bool
	 * @access public
	 */
	public function deployAll($dry_run = false)
	{
		if ( !$this->isCommandLine ) {
			echo '<pre style="font-size: 10pt; color: #BBB; background-color: black; border: 2px solid darkgreen; padding: 8px;">' . PHP_EOL;
		}

		$ret = true;
		$this->dryRun = $dry_run;

		$this->toLog(PHP_EOL . '[' . adodb_date('Y-m-d H:i:s') . '] === ' . $this->ip . ' ===');

		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 ( $ret && !$this->dryRun ) {
			$this->resetCaches();
			$this->refreshThemes();
			$this->dumpAssets();
		}

		if ( !$this->isCommandLine ) {
			echo kUtil::escape($this->_runShellScript());
			echo '</pre>' . PHP_EOL;
		}

		return $ret;
	}

	/**
	 * Runs user-specific shell script when deployment happens from Web
	 *
	 * @return string
	 * @access protected
	 */
	protected function _runShellScript()
	{
		if ( !$this->Application->isDebugMode(false) ) {
			return '';
		}

		$wrapper_script = '/usr/local/bin/guest2host_server.sh';
		$script_name = FULL_PATH .'/tools/' . ($this->dryRun ? 'synchronize.sh' : 'deploy.sh');

		if ( file_exists($wrapper_script) && file_exists($script_name) ) {
			$script_name = preg_replace('/^.*\/web/', constant('DBG_LOCAL_BASE_PATH'), $script_name);

			return shell_exec($wrapper_script . ' ' . $script_name . ' 2>&1');
		}

		return '';
	}

	/**
	 * Deploys pending changes to a site
	 *
	 * @param string $module_name
	 * @return bool
	 * @access private
	 */
	private function deploy($module_name)
	{
		echo $this->colorText('Deploying Module "' . $module_name . '":', 'cyan', true) . PHP_EOL;

		if ( !$this->upgradeDatabase() ) {
			return false;
		}

		try {
			if ( $this->dryRun ) {
				$this->exportLanguagePack();
			}
			else {
				$this->importLanguagePack();
			}
		}
		catch ( Exception $e ) {
			echo $this->colorText('Failed with Module "' . $module_name . '".', 'red', true) . PHP_EOL . PHP_EOL;

			return false;
		}

		echo $this->colorText('Done with Module "' . $module_name . '".', 'green', true) . PHP_EOL . PHP_EOL;

		return true;
	}

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

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

	/**
	 * Exports latest language pack
	 *
	 * @return void
	 * @access private
	 */
	private function exportLanguagePack()
	{
		static $languages = null;

		if ( !isset($languages) ) {
			$sql = 'SELECT LanguageId
					FROM ' . $this->Application->getUnitOption('lang', 'TableName') . '
					WHERE Enabled = 1';
			$languages = $this->Conn->GetCol($sql);
		}

		/** @var LanguageImportHelper $language_import_helper */
		$language_import_helper = $this->Application->recallObject('LanguageImportHelper');

		$this->out('Exporting LanguagePack ... ');
		$language_import_helper->performExport(
			EXPORT_PATH . '/' . $this->moduleName . '.lang',
			'|0|1|2|',
			$languages,
			'|' . $this->moduleName . '|'
		);
		$this->displayStatus('OK');
	}

	/**
	 * Resets unit and section cache
	 *
	 * @return void
	 * @access private
	 */
	private function resetCaches()
	{
		// 2. reset unit config cache (so new classes get auto-registered)
		$this->out('Resetting Configs Files Cache and Parsed System Data ... ');
		$this->_event->CallSubEvent('OnResetConfigsCache');
		$this->displayStatus('OK');

		// 3. reset sections cache
		$this->out('Resetting Admin Console Sections ... ');
		$this->_event->CallSubEvent('OnResetSections');
		$this->displayStatus('OK');

		// 4. reset mod-rewrite cache
		$this->out('Resetting ModRewrite Cache ... ');
		$this->_event->CallSubEvent('OnResetModRwCache');
		$this->displayStatus('OK');
	}

	/**
	 * Rebuild theme files
	 *
	 * @return void
	 * @access private
	 */
	private function refreshThemes()
	{
		$this->out('Refreshing Theme Files ... ');
		$this->_event->CallSubEvent('OnRebuildThemes');
		$this->displayStatus('OK');
	}

	/**
	 * Dumps assets
	 *
	 * @return void
	 */
	private function dumpAssets()
	{
		$this->out('Dumping Assets ... ');
		$this->_event->CallSubEvent('OnDumpAssets');
		$this->displayStatus('OK');
	}

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

		$this->out('Verifying Database Revisions ... ');

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

		$this->displayStatus('OK');

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

		return $applied;
	}

	/**
	 * Collects database revisions from "project_upgrades.sql" file.
	 *
	 * @return bool
	 * @access private
	 */
	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 ) {
			$this->displayStatus('FAILED' . PHP_EOL . 'No Database Revisions Found');

			return false;
		}

		$revision_numbers = array();

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

			if ( in_array($revision, $revision_numbers) ) {
				$this->displayStatus('FAILED' . PHP_EOL . 'Duplicate revision #' . $revision . ' found');

				return false;
			}

			$revision_numbers[] = $revision;

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

			// 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->revisionTitles[$revision] = trim($match[0][0]);
			$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
	 * @access private
	 */
	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 ) {
					$this->displayStatus('FAILED' . PHP_EOL . 'Revision #' . $revision . ' has incorrect dependency to revision #' . $revision_dependency . '. Only dependencies to older revisions are allowed!');

					return false;
				}

				if ( !isset($this->revisionSqls[$revision_dependency]) ) {
					$this->displayStatus('FAILED' . PHP_EOL . 'Revision #' . $revision . ' depends on missing revision #' . $revision_dependency . '!');

					return false;
				}
			}
		}

		return true;
	}

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

		if ( $this->dryRun ) {
			$this->out('Simulating Database Upgrade ... ', true);

			foreach ( $this->revisionSqls as $revision => $sqls ) {
				echo PHP_EOL . $this->colorText($this->revisionTitles[$revision], 'gray', true) . PHP_EOL;
				echo '...' . PHP_EOL;
			}

			echo PHP_EOL;

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

			return true;
		}

		$this->out('Upgrading Database ... ', true);

		foreach ($this->revisionSqls as $revision => $sqls) {
			echo PHP_EOL . $this->colorText($this->revisionTitles[$revision], 'gray', true) . PHP_EOL; // 'Processing DB Revision: #' . $revision . ' ... ';

			$sqls = str_replace("\r\n", "\n", $sqls); // convert to linux line endings
			$no_comment_sqls = preg_replace("/#\s([^;]*?)\n/is", "# \\1;\n", $sqls); // add ";" to each comment end to ensure correct split

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

			try {
				foreach ($sqls as $sql) {
					if ( substr($sql, 0, 1) == '#' ) {
						// output comment as is
						$this->toLog($sql);
						echo $this->colorText($sql, 'purple') . PHP_EOL;
						continue;
					}
					elseif ( $sql ) {
						$this->toLog($sql . ' ... ', false);
						$escaped_sql = $this->isCommandLine ? $sql : kUtil::escape($sql);
						echo mb_substr(trim(preg_replace('/(\n|\t| )+/is', ' ', $escaped_sql)), 0, self::SQL_TRIM_LENGTH) . ' ... ';

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

						$this->toLog('OK (' . $this->Conn->getAffectedRows() . ')');
						$this->displayStatus('OK (' . $this->Conn->getAffectedRows() . ')');
					}
				}
			}
			catch (Exception $e) {
				// consider revisions with errors applied
				$this->appliedRevisions[] = $revision;

				return false;
			}

			$this->appliedRevisions[] = $revision;
		}

		echo PHP_EOL;

		return true;
	}

	/**
	 * Error handler for sql errors.
	 *
	 * @param integer $code Error code.
	 * @param string  $msg  Error message.
	 * @param string  $sql  SQL query.
	 *
	 * @return void
	 * @throws Exception When SQL error happens.
	 */
	public function handleSqlError($code, $msg, $sql)
	{
		$this->toLog('FAILED' . PHP_EOL . 'SQL Error #' . $code . ': ' . $msg);

		$this->displayStatus('FAILED' . PHP_EOL . 'SQL Error #' . $code . ': ' . $msg);
		$this->out('Please execute rest of SQLs in this Revision by hand and run deployment script again.', true);

		throw new Exception($msg, $code);
	}

	/**
	 * Checks if given revision was already applied
	 *
	 * @param int $revision
	 * @return bool
	 * @access private
	 */
	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
	 * @access private
	 */
	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
	 * @access private
	 */
	private function parseRevisionDependencies($string)
	{
		if ( !$string ) {
			return Array ();
		}

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

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

	/**
	 * Applies requested color and bold attributes to given text string
	 *
	 * @param string $text
	 * @param string $color
	 * @param bool $bold
	 * @return string
	 * @access private
	 */
	private function colorText($text, $color, $bold = false)
	{
		if ( $this->isCommandLine ) {
			$color_map = Array (
				'black' => 30, // dark gray (in bold)
				'blue' => 34, // light blue (in bold)
				'green' => 32, // light green (in bold)
				'cyan' => 36, // light cyan (in bold)
				'red' => 31, // light red (in bold)
				'purple' => 35, // light purple (in bold)
				'brown' => 33, // yellow (in bold)
				'gray' => 37, // white (in bold)
			);

			return "\033[" . ($bold ? 1 : 0) . ";" . $color_map[$color] . "m" . $text . "\033[0m";
		}

		$html_color_map = Array (
			'black' => Array ('normal' => '#000000', 'bold' => '#666666'),
			'blue' => Array ('normal' => '#00009C', 'bold' => '#3C3CFF'),
			'green' => Array ('normal' => '#009000', 'bold' => '#00FF00'),
			'cyan' => Array ('normal' => '#009C9C', 'bold' => '#00FFFF'),
			'red' => Array ('normal' => '#9C0000', 'bold' => '#FF0000'),
			'purple' => Array ('normal' => '#900090', 'bold' => '#F99CF9'),
			'brown' => Array ('normal' => '#C9C909', 'bold' => '#FFFF00'),
			'gray' => Array ('normal' => '#909090', 'bold' => '#FFFFFF'),
		);

		$html_color = $html_color_map[$color][$bold ? 'bold' : 'normal'];

		return '<span style="color: ' . $html_color . '">' . kUtil::escape($text, kUtil::ESCAPE_HTML) . '</span>';
	}

	/**
	 * Displays last command execution status
	 *
	 * @param string $status_text
	 * @param bool $new_line
	 * @return void
	 * @access private
	 */
	private function displayStatus($status_text, $new_line = true)
	{
		$color = substr($status_text, 0, 2) == 'OK' ? 'green' : 'red';

		echo $this->colorText($status_text, $color, false);

		if ( $new_line ) {
			echo PHP_EOL;
		}
	}

	/**
	 * Outputs a text and escapes it if necessary
	 *
	 * @param string $text
	 * @param bool $new_line
	 * @return void
	 */
	private function out($text, $new_line = false)
	{
		if ( !$this->isCommandLine ) {
			$text = kUtil::escape($text);
		}

		echo $text . ($new_line ? PHP_EOL : '');
	}
}
