<?php
/**
 * @author Viktar Dubiniuk <dubiniuk@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 Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Type;
use OC\RepairException;
use OC_App;
use OCP\App\AppAlreadyInstalledException;
use OCP\App\AppManagerException;
use OCP\App\AppNotFoundException;
use OCP\App\AppNotInstalledException;
use OCP\App\AppUpdateNotFoundException;
use OCP\App\IAppManager;
use OCP\IConfig;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use OCP\Util;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;

class Apps implements IRepairStep {
	public const KEY_COMPATIBLE = 'compatible';
	public const KEY_INCOMPATIBLE = 'incompatible';
	public const KEY_MISSING = 'missing';

	/** @var  IAppManager */
	private $appManager;

	/** @var  EventDispatcherInterface */
	private $eventDispatcher;

	/** @var IConfig */
	private $config;

	/** @var \OC_Defaults */
	private $defaults;

	/** @var bool */
	private $forceMajorUpgrade;

	/**
	 * Apps constructor.
	 *
	 * @param IAppManager $appManager
	 * @param EventDispatcherInterface $eventDispatcher
	 * @param IConfig $config
	 * @param \OC_Defaults $defaults
	 */
	public function __construct(IAppManager $appManager, EventDispatcherInterface $eventDispatcher, IConfig $config, \OC_Defaults $defaults, $forceMajorUpgrade = false) {
		$this->appManager = $appManager;
		$this->eventDispatcher = $eventDispatcher;
		$this->config = $config;
		$this->defaults = $defaults;
		$this->forceMajorUpgrade = $forceMajorUpgrade;
	}

	/**
	 * @return string
	 */
	public function getName() {
		return 'Upgrade app code from the marketplace';
	}

	/**
	 * Are we updating from an older version?
	 * @return bool
	 */
	private function isCoreUpdate() {
		$installedVersion = $this->config->getSystemValue('version', '0.0.0');
		$currentVersion = \implode('.', $this->getSourcesVersion());
		$versionDiff = \version_compare($currentVersion, $installedVersion);
		if ($versionDiff > 0) {
			return true;
		}
		return false;
	}

	/**
	 * Is it a major core update
	 *
	 * @return bool
	 */
	private function isMajorCoreUpdate() {
		if ($this->forceMajorUpgrade === true) {
			return true;
		}

		$installedVersion = $this->config->getSystemValue('version', '0.0.0');
		$installedVersionArray = \explode('.', $installedVersion);
		$installedVersionMajor = (int) $installedVersionArray[0];
		$targetVersionArray = $this->getSourcesVersion();
		$targetVersionMajor = (int) $targetVersionArray[0];
		$majorUpgrade = $targetVersionMajor !== $installedVersionMajor;

		return $majorUpgrade;
	}

	/**
	 * If we are updating from <= 10.0.0 we need to enable the marketplace before running the update
	 * @return bool
	 */
	private function requiresMarketEnable() {
		$installedVersion = $this->config->getSystemValue('version', '0.0.0');
		$versionDiff = \version_compare('10.0.0', $installedVersion);
		if ($versionDiff < 0) {
			return false;
		}
		return true;
	}

	/**
	 * @param IOutput $output
	 * @throws RepairException
	 */
	public function run(IOutput $output) {
		if ($this->config->getSystemValue('has_internet_connection', true) !== true) {
			$link = $this->defaults->buildDocLinkToKey('admin-marketplace-apps');
			$output->info('No internet connection available - no app updates will be taken from the marketplace.');
			$output->info("How to update apps in such situation please see $link");
			$this->appManager->disableApp('market');
		}
		$appsToUpgrade = $this->getAppsToUpgrade();
		$failedCompatibleApps = [];
		$failedMissingApps = $appsToUpgrade[self::KEY_MISSING];
		$failedIncompatibleApps = $appsToUpgrade[self::KEY_INCOMPATIBLE];
		$hasNotUpdatedCompatibleApps = 0;

		// fix market app state
		$shallContactMarketplace = $this->fixMarketAppState($output);

		// market might be enabled but admin does not want to automatically update apps through it
		// (they might want to manually click through the updates in the web UI so keeping the
		// market enabled here is a legitimate use case)
		if ($this->config->getSystemValue('upgrade.automatic-app-update', true) !== true) {
			$shallContactMarketplace = false;
		}

		if ($shallContactMarketplace) {
			// Check if we can use the marketplace to update apps as needed?
			if ($this->appManager->isEnabledForUser('market')) {
				// Use market to fix missing / old apps
				$this->loadApp('market');
				$output->info('Using market to update existing apps');
				try {
					// Try to update incompatible apps
					if (!empty($appsToUpgrade[self::KEY_INCOMPATIBLE])) {
						$output->info('Attempting to update the following existing but incompatible app from market: ' . \implode(', ', $appsToUpgrade[self::KEY_INCOMPATIBLE]));
						$failedIncompatibleApps = $this->getAppsFromMarket(
							$output,
							$appsToUpgrade[self::KEY_INCOMPATIBLE],
							'upgradeAppStoreApp'
						);
					}

					// Try to download missing apps
					if (!empty($appsToUpgrade[self::KEY_MISSING])) {
						$output->info('Attempting to update the following missing apps from market: ' . \implode(', ', $appsToUpgrade[self::KEY_MISSING]));
						$failedMissingApps = $this->getAppsFromMarket(
							$output,
							$appsToUpgrade[self::KEY_MISSING],
							'reinstallAppStoreApp'
						);
					}

					// Try to update compatible apps
					if (!empty($appsToUpgrade[self::KEY_COMPATIBLE])) {
						$output->info('Attempting to update the following existing compatible apps from market: ' . \implode(', ', $appsToUpgrade[self::KEY_COMPATIBLE]));
						$failedCompatibleApps = $this->getAppsFromMarket(
							$output,
							$appsToUpgrade[self::KEY_COMPATIBLE],
							'upgradeAppStoreApp'
						);
					}

					$hasNotUpdatedCompatibleApps = \count($failedCompatibleApps);
				} catch (AppManagerException $e) {
					$output->warning($e->getMessage());
				}
			} else {
				// No market available, output error and continue attempt
				$link = $this->defaults->buildDocLinkToKey('admin-marketplace-apps');
				$output->warning("Market app is unavailable for updating of apps. Please update manually, see $link");
			}
		}

		$hasBlockingMissingApps = \count($failedMissingApps);
		$hasBlockingIncompatibleApps = $this->hasBlockingIncompatibleApps($failedIncompatibleApps);

		if ($hasBlockingIncompatibleApps || $hasBlockingMissingApps) {
			// fail
			$output->warning('You have incompatible or missing apps enabled that could not be found or updated via the marketplace.');
			$output->warning(
				'Please install or update the following apps manually or disable them with:'
				. $this->getOccDisableMessage(\array_merge($failedIncompatibleApps, $failedMissingApps))
			);
			$link = $this->defaults->buildDocLinkToKey('admin-marketplace-apps');
			$output->warning("For manually updating, see $link");

			throw new RepairException('Upgrade is not possible');
		} elseif ($hasNotUpdatedCompatibleApps) {
			foreach ($failedCompatibleApps as $app) {
				// TODO: Show reason
				$output->info("App was not updated: $app");
			}
		}
	}

	/**
	 * Upgrade appList from market
	 * Return an array of apps that were not upgraded successfully
	 *
	 * @param IOutput $output
	 * @param string[] $appList
	 * @param string $event
	 * @return array
	 * @throws AppManagerException
	 */
	protected function getAppsFromMarket(IOutput $output, $appList, $event) {
		$failedApps = [];
		foreach ($appList as $app) {
			$output->info("Fetching app from market: $app");
			try {
				$this->eventDispatcher->dispatch(
					new GenericEvent(
						$app,
						['isMajorUpdate' => $this->isMajorCoreUpdate()]
					),
					\sprintf('%s::%s', IRepairStep::class, $event)
				);
			} catch (AppAlreadyInstalledException $e) {
				$output->info($e->getMessage());
				$failedApps[] = $app;
			} catch (AppNotInstalledException $e) {
				$output->info($e->getMessage());
				$failedApps[] = $app;
			} catch (AppNotFoundException $e) {
				$output->info($e->getMessage());
				$failedApps[] = $app;
			} catch (AppUpdateNotFoundException $e) {
				$output->info($e->getMessage());
				$failedApps[] = $app;
			} catch (AppManagerException $e) {
				// No connection to market. Abort.
				throw $e;
			} catch (\Exception $e) {
				// TODO: check the reason
				$failedApps[] = $app;
				$output->warning(\get_class($e));

				$output->warning($e->getMessage());
			}
		}
		return $failedApps;
	}

	/**
	 * Get app list separated as compatible/incompatible/missing
	 *
	 * @return array
	 */
	protected function getAppsToUpgrade() {
		$installedApps = $this->appManager->getInstalledApps();
		$appsToUpgrade = [
			self::KEY_COMPATIBLE => [],
			self::KEY_INCOMPATIBLE => [],
			self::KEY_MISSING => []
		];

		foreach ($installedApps as $appId) {
			$info = $this->appManager->getAppInfo($appId);
			if (!isset($info['id']) || $info['id'] === null) {
				$appsToUpgrade[self::KEY_MISSING][] = $appId;
				continue;
			}
			$version = Util::getVersion();
			$key = (\OC_App::isAppCompatible($version, $info)) ? self::KEY_COMPATIBLE : self::KEY_INCOMPATIBLE;
			$appsToUpgrade[$key][] = $appId;
		}
		return $appsToUpgrade;
	}

	protected function getOccDisableMessage($appList) {
		if (!\count($appList)) {
			return '';
		}
		$appList = \array_map(
			function ($appId) {
				return "occ app:disable $appId";
			},
			$appList
		);
		return "\n" . \implode("\n", $appList);
	}

	/**
	 * @param string[] $failedIncompatibleApps
	 *
	 * @return bool
	 */
	protected function hasBlockingIncompatibleApps($failedIncompatibleApps) {
		$skipBlockingAppsCheck = \in_array(Util::getChannel(), ['git', 'daily'], true);
		$hasBlockingIncompatibleApps = $skipBlockingAppsCheck === false && \count($failedIncompatibleApps);
		return $hasBlockingIncompatibleApps;
	}

	/**
	 * @codeCoverageIgnore
	 * @param string $app
	 */
	protected function loadApp($app) {
		OC_App::loadApp($app, false);
	}

	/**
	 * @return bool
	 */
	private function isAppStoreEnabled() {
		// if appstoreenabled was explicitly disabled we shall not use the market app for upgrade
		$appStoreEnabled = $this->config->getSystemValue('appstoreenabled', null);
		if ($appStoreEnabled === false) {
			return false;
		}
		return true;
	}

	private function fixMarketAppState(IOutput $output) {
		// no core update -> nothing to do
		if (!$this->isCoreUpdate()) {
			return false;
		}

		// no update from a version before 10.0 -> nothing to do, but allow apps to be updated
		if (!$this->requiresMarketEnable()) {
			return true;
		}
		// if the appstore was explicitly disabled -> disable market app as well
		if (!$this->isAppStoreEnabled()) {
			$this->appManager->disableApp('market');
			$link = $this->defaults->buildDocLinkToKey('admin-marketplace-apps');
			$output->info('Appstore was disabled in past versions and marketplace interactions are disabled for now as well.');
			$output->info('If you would like to get automated app updates on upgrade please enable the market app and remove "appstoreenabled" from your config.');
			$output->info("Please note that the market app is not recommended for clustered setups - see $link");
			return false;
		}

		// Then we need to enable the market app to support app updates / downloads during upgrade
		$output->info('Enabling market app to assist with update');
		try {
			// Prepare oc_jobs for older ownCloud version fixes https://github.com/owncloud/update-testing/issues/5
			$connection = \OC::$server->getDatabaseConnection();
			// IDBConnection does not have getPrefix, but Conection does
			'@phan-var \OC\DB\Connection $connection';
			$toSchema = $connection->createSchema();
			$this->changeSchema($toSchema, ['tablePrefix' => $connection->getPrefix()]);
			$connection->migrateToSchema($toSchema);

			$this->appManager->enableApp('market');
			return true;
		} catch (\Exception $ex) {
			$output->warning($ex->getMessage());
			return false;
		}
	}

	/**
	 * DB update for oc_jobs table
	 * it is intentionally duplicates 20170213215145 and a part of 20170101215145
	 * to allow seamless market app installation
	 *
	 * @param Schema $schema
	 * @param array $options
	 * @throws \Doctrine\DBAL\Schema\SchemaException
	 */
	private function changeSchema(Schema $schema, array $options) {
		$prefix = $options['tablePrefix'];
		if ($schema->hasTable("{$prefix}jobs")) {
			$jobsTable = $schema->getTable("{$prefix}jobs");

			if (!$jobsTable->hasColumn('last_checked')) {
				$jobsTable->addColumn(
					'last_checked',
					Type::INTEGER,
					[
						'default' => 0,
						'notnull' => false
					]
				);
			}

			if (!$jobsTable->hasColumn('reserved_at')) {
				$jobsTable->addColumn(
					'reserved_at',
					Type::INTEGER,
					[
						'default' => 0,
						'notnull' => false
					]
				);
			}

			if (!$jobsTable->hasColumn('execution_duration')) {
				$jobsTable->addColumn('execution_duration', Type::INTEGER, [
					'notnull' => true,
					'length' => 5,
					'default' => -1,
				]);
			}
		}
	}

	/**
	 * @return array
	 */
	protected function getSourcesVersion() {
		return Util::getVersion();
	}
}
