<?php namespace ProcessWire;

/**
 * Class SystemUpdater
 * 
 * ProcessWire System Helper Module
 *
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 * 
 * @method coreVersionChange($fromVersion, $toVersion)
 *
 */

class SystemUpdater extends WireData implements Module, ConfigurableModule {

	public static function getModuleInfo() {
		return array(
			'title' => __('System Updater', __FILE__), // Module Title
			'summary' => __('Manages system versions and upgrades.', __FILE__), // Module Summary
			'permanent' => true, 
			'singular' => true, 
			'autoload' => false, 

			/**
			 * This version number is important, as this updater keeps the systemVersion up with this version
			 *
			 */
			'version' => 20, 
		);
	}

	protected $configData = array(
		// systemVersion generally represents the DB schema version, but 
		// can represent anything about the system that's related to the individual installation.
		// 0 = the first version when this module was created, should remain there. 
		'systemVersion' => 0, 
	);

	/**
	 * Number of updates that were applied during this request
	 *
	 */
	protected $numUpdatesApplied = 0;

	/**
	 * Get array of updates that were applied
	 * 
	 * @var array Array of update version numbers
	 * 
	 */
	protected $updatesApplied = array();

	/**
	 * Is an update being applied manually?
	 * 
	 * @var bool|int Contains update number when one is being manually applied
	 * 
	 */
	protected $manualVersion = false;

	/**
	 * Part of the ConfigurableModule interface, sets config data to the module
	 * 
	 * @param array $data
	 *
	 */
	public function setConfigData(array $data) {
		$this->configData = array_merge($this->configData, $data);
	}
	
	/**
	 * Perform version checks and update as needed
	 *
	 */
	public function init() {

		$config = $this->wire()->config;
		$info = self::getModuleInfo();
		$moduleVersion = $info['version'];
		
		foreach($this->configData as $key => $value) {
			if($key == 'coreVersion') continue;
			$config->$key = $value; 
		}
		
		$systemVersion = (int) $config->systemVersion;
		
		if(empty($systemVersion)) {
			// double check, just in case (should not be possible for this to occur)
			$this->configData = $this->wire()->modules->getModuleConfigData($this);
			$systemVersion = (int) isset($this->configData['systemVersion']) ? $this->configData['systemVersion'] : 0;
		}

		while($systemVersion < $moduleVersion) {

			// apply the incremental version update
			if(!$this->update($systemVersion+1)) break;

			// we increment the config systemVersion so that the version is also available to the updater
			$systemVersion++;

			// we save the configData for every version in case an update throws an exception
			// then already applied updates won't be applied again
			$this->saveSystemVersion($systemVersion); 
			$this->numUpdatesApplied++;
			$this->updatesApplied[] = ($systemVersion-1);
		}

		if($this->numUpdatesApplied > 0) {
			// if updates were applied, reset the modules cache 
			$this->modules->resetCache();
		}

	}

	/**
	 * Called after ProcessWire API ready
	 * 
	 */
	public function ready() {
		static $called = false;
		if($called) return; // just in case we add auto-ready support to non-autoload modules 
		
		if($this->wire()->page->template != 'admin') return;
		
		$config = $this->wire()->config;
		if($config->ajax) return;
		
		$coreVersion = isset($this->configData['coreVersion']) ? $this->configData['coreVersion'] : '';
		$configVersion = $config->version;
		if($coreVersion != $configVersion) $this->coreVersionChange($coreVersion, $configVersion);
		$called = true;
	}

	/**
	 * Hook called when the core version changes, in case any listeners want it
	 * 
	 * Note that version change is only detected when a page from the admin is viewed. 
	 * To hook this, hook to "SystemUpdater::coreVersionChange"
	 * 
	 * @param string $fromVersion
	 * @param string $toVersion
	 * 
	 */
	protected function ___coreVersionChange($fromVersion, $toVersion) {
		
		$modules = $this->wire()->modules;
		$session = $this->wire()->session;
		$config = $this->wire()->config;
		
		$this->message(sprintf($this->_('Detected core version change %1$s => %2$s'), $fromVersion, $toVersion));
		
		if( (strpos($fromVersion, '2') === 0 && strpos($toVersion, '3') === 0) ||
			(strpos($fromVersion, '3') === 0 && strpos($toVersion, '2') === 0)) {
			// clear FileCompiler cache
			if($config->templateCompile || $config->moduleCompile) {
				/** @var FileCompiler $compiler */
				$compiler = $this->wire(new FileCompiler($config->paths->templates));
				$compiler->clearCache(true);
				$this->message($this->_('Cleared file compiler cache')); 
			}
		}

		if(!$this->numUpdatesApplied) {
			// reset modules cache, only if it hasn't been reset already by a system update
			$modules->resetCache();
		}
		
		$this->configData['coreVersion'] = $toVersion;
		$modules->saveModuleConfigData($this, $this->configData);
		
		// remove admin theme cached info in session
		foreach($session as $key => $value) {
			if(strpos($key, 'AdminTheme') === 0) {
				$session->remove($key);
			}
		}
	}

	/**
	 * Save the system version as the given version number
	 *
	 * @param int $version
	 * @return bool
	 *
	 */ 
	public function saveSystemVersion($version) {
		if($this->manualVersion == $version) return false;
		$config = $this->wire()->config;
		$version = (int) $version;
		$config->systemVersion = $version; 
		$this->configData['systemVersion'] = $version;
		$this->configData['coreVersion'] = $config->version;
		$this->wire()->modules->saveModuleConfigData($this, $this->configData);
		$this->message("Update #$version: Completed!"); 
		return true;
	}

	/**
	 * Check for an update file in the format: SystemUpdater123 where '123' is the version it upgrades to
	 *
 	 * If found, instantiate the class and its constructor should perform the update or add any hooks necessary to perform the update
	 * 
	 * @param int $version
	 * @return bool
	 *
	 */ 
	protected function update($version) {
		
		$errorMessage = sprintf('Failed to apply update %d', $version); 
		$update = null;
		
		try {
			$update = $this->getUpdate($version);
			if(!$update) return true;
			$update->message('Initializing update'); 
			$success = $update->execute();
			if($success === false) $update->error($errorMessage);
		} catch(\Exception $e) {
			$msg = $errorMessage . " - " . $e->getMessage(); 
			$messenger = $update ? $update : $this;
			$messenger->error($msg);
			$success = false;
		}
		
		return $success;
	}

	/**
	 * Get a specific SystemUpdate class instance by version number and return it (without executing it)
	 * 
	 * @param int $version Update version number
	 * @return null|SystemUpdate Returns SystemUpdate instance of available or null if not
	 * @since 3.0.135
	 * 
	 */
	public function getUpdate($version) {
	
		$path = dirname(__FILE__) . '/';
		
		require_once($path . 'SystemUpdate.php');
		
		$className = 'SystemUpdate' . $version;
		$filename = $path . $className . '.php';
		
		if(!is_file($filename)) return null;
		
		require_once($filename);

		$className = wireClassName($className, true);
		
		/** @var SystemUpdate $update */
		$update = $this->wire(new $className($this));
		
		return $update;
	}

	/**
	 * Manually apply a update 
	 * 
	 * The system version is not changed when applying an update manually.
	 * 
	 * @param int|SystemUpdate $version Update version number or instance of SystemUpdate you want to apply
	 * @return bool True on success, false on fail
	 * @since 3.0.135
	 * 
	 */
	public function apply($version) {
		
		if(is_object($version)) {
			$update = $version;
			$version = $update->getVersion();
		} else {
			$update = null;
			$version = (int) $version;
		}
		
		$this->manualVersion = $version;
		
		try {
			if(!$update) $update = $this->getUpdate($version);
			$success = $update ? $update->execute() : true;
		} catch(\Exception $e) {
			$this->error($e->getMessage());
			$success = false;
		}
		
		$this->manualVersion = false;
		
		return $success;
	}

	/**
	 * Get instance of SystemUpdaterChecks for performing system checks
	 * 
	 * #pw-internal
	 * 
	 * @return SystemUpdaterChecks
	 * @since 3.0.135
	 * 
	 */
	public function getChecks() {
		require_once(dirname(__FILE__) . '/SystemUpdaterChecks.php');
		$checks = new SystemUpdaterChecks();
		$this->wire($checks);
		return $checks;
	}

	/**
	 * Get array of updates (update version numbers) that were automatically applied during this request
	 * 
	 * @return array
	 * @since 3.0.135
	 * 
	 */
	public function getUpdatesApplied() {
		return $this->updatesApplied;
	}

	/**
	 * Message notice
	 * 
	 * @param string $text
	 * @param int $flags
	 * @return SystemUpdater|WireData
	 * 
	 */
	public function message($text, $flags = 0) {
		$this->log($text);
		return parent::message($text, $flags);
	}
	
	/**
	 * Warning notice
	 *
	 * @param string $text
	 * @param int $flags
	 * @return SystemUpdater|WireData
	 *
	 */
	public function warning($text, $flags = 0) {
		$text = "WARNING: $text";
		$this->log($text);
		return parent::warning($text, $flags);
	}


	/**
	 * Error notice
	 * 
	 * @param string $text
	 * @param int $flags
	 * @return SystemUpdater|WireData
	 * 
	 */
	public function error($text, $flags = 0) {
		$text = "ERROR: $text";
		$this->log($text);
		return parent::error($text, $flags);
	}

	/**
	 * Log a message to system-updater.txt log file
	 *
	 * @param string $str
	 * 
	 */
	public function log($str) {
		$options = array('showUser' => false, 'showPage' => false); 
		$this->wire()->log->save('system-updater', $str, $options); 
	}

	/**
	 * Required for ConfigurableModule interface
	 * 
	 * @param array $data
	 * @return InputfieldWrapper
	 *
	 */
	public function getModuleConfigInputfields(array $data) {
		
		$modules = $this->wire()->modules;
		$config = $this->wire()->config;
		$sanitizer = $this->wire()->sanitizer;
		
		$inputfields = $this->wire(new InputfieldWrapper());

		$logfile = $config->paths->logs . 'system-updater.txt';
		if(is_file($logfile)) {
			/** @var InputfieldMarkup $f */
			$f = $modules->get('InputfieldMarkup'); 	
			$f->attr('name', '_log'); 
			$f->label = $this->_('System Update Log'); 
			$logContent = $sanitizer->unentities(file_get_contents($logfile)); 
			$logContent = preg_replace('!<a href=.+?>(.+?)</a>!', '$1', $logContent);
			$f->value = '<pre>' . $sanitizer->entities($logContent) . '</pre>';
			$inputfields->add($f); 
		}
	
		/** @var InputfieldInteger $f */
		$f = $modules->get('InputfieldInteger'); 
		$f->attr('name', 'systemVersion'); 
		$f->label = $this->_('System Version'); 
		$f->description = $this->_('This lets you re-apply a system version update by reducing the version number.');
		$f->attr('value', $data['systemVersion']); 
		$inputfields->add($f);

		/** @var InputfieldHidden $f */
		$f = $modules->get('InputfieldHidden');
		$f->attr('name', 'coreVersion');
		$f->attr('value', $config->version);
		$inputfields->add($f);

		return $inputfields;
	}


}
