<?php

namespace XF\Install;

use Symfony\Component\Cache\Adapter\AdapterInterface;
use XF\App;
use XF\Db\AbstractAdapter;
use XF\Db\Schema\Alter;
use XF\Install\Upgrade\AbstractUpgrade;
use XF\Job\CollectStats;
use XF\Job\FileCleanUp;
use XF\Job\UpgradeCheck;
use XF\Mvc\Entity\Entity;
use XF\Repository\CollectStatsRepository;
use XF\Util\Arr;
use XF\Util\File;

use function array_slice, intval;

class Upgrader
{
	/**
	 * @var App
	 */
	protected $app;

	protected $currentVersion = null;

	public function __construct(App $app)
	{
		$this->app = $app;
	}

	/**
	 * @return AbstractAdapter
	 */
	protected function db()
	{
		return $this->app->db();
	}

	protected function helper()
	{
		return new Helper($this->app);
	}

	public function insertUpgradeLog($versionId, $lastStep = null)
	{
		$helper = $this->helper();
		$helper->insertUpgradeLog($versionId, $lastStep);
	}

	public function syncUpgradeLogStructure(): void
	{
		$sm = $this->db()->getSchemaManager();

		if (!$sm->columnExists('xf_upgrade_log', 'last_step'))
		{
			$sm->alterTable('xf_upgrade_log', function (Alter $table)
			{
				$table->addColumn('last_step', 'smallint')->nullable();
				$table->dropColumns('user_id');
			});
		}
	}

	public function syncJobStructure(): void
	{
		$sm = $this->db()->getSchemaManager();

		if (!$sm->tableExists('xf_job'))
		{
			return;
		}

		$sm->alterTable('xf_job', function (Alter $table) use ($sm)
		{
			if (!$sm->columnExists('xf_job', 'attempts'))
			{
				$table->addColumn('attempts', 'tinyint', 3)->setDefault(0);
			}
			if (!$sm->columnExists('xf_job', 'priority'))
			{
				$table->addColumn('priority', 'smallint', 5)->setDefault(100);
				$table->addKey(['priority', 'trigger_date'], 'priority_execute_date');
			}
		});
	}

	public function completeUpgrade()
	{
		$helper = $this->helper();
		$helper->updateVersion();

		$this->enqueuePostUpgradeJobs();
		$this->clearUpgradeJobs();

		$this->enqueueFileCleanUp();
		$this->enqueueUpgradeCheck();
		$this->enqueueStatsCollection();

		$this->clearRegistryCache();
	}

	public function getLatestUpgradeVersion()
	{
		return $this->db()->fetchRow('
			SELECT *
			FROM xf_upgrade_log
			ORDER BY version_id DESC
			LIMIT 1
		');
	}

	public function isUpgradeComplete()
	{
		$lastUpgradeVersion = $this->getLatestUpgradeVersion();

		return ($lastUpgradeVersion['version_id'] === \XF::$versionId && !$lastUpgradeVersion['last_step']);
	}

	public function getPossibleUpgradeFileNames()
	{
		$searchDir = \XF::getSourceDirectory() . '/XF/Install/Upgrade';

		$upgrades = [];
		foreach (glob($searchDir . '/*.php') AS $file)
		{
			$file = basename($file);

			switch ($file)
			{
				case '1010031-100b1.php': // this was badly named - make sure it's always skipped so the right one runs
					continue 2;
			}

			$versionId = intval($file);
			if (!$versionId)
			{
				continue;
			}

			$upgrades[$versionId] = $searchDir . '/' . $file;
		}

		ksort($upgrades, SORT_NUMERIC);

		return $upgrades;
	}

	public function getRemainingUpgradeVersionIds($lastCompletedVersion)
	{
		$upgrades = $this->getPossibleUpgradeFileNames();
		$offset = 0;

		foreach ($upgrades AS $upgrade => $file)
		{
			if ($upgrade > $lastCompletedVersion)
			{
				return array_slice($upgrades, $offset, null, true);
			}

			$offset++;
		}

		return [];
	}

	public function getNextUpgradeVersionId($lastCompletedVersion)
	{
		$upgrades = $this->getRemainingUpgradeVersionIds($lastCompletedVersion);
		reset($upgrades);
		return key($upgrades);
	}

	public function getNewestUpgradeVersionId()
	{
		$upgrades = $this->getRemainingUpgradeVersionIds(0);
		end($upgrades);
		return key($upgrades);
	}

	/**
	 * @param integer $versionId
	 * @param App $app
	 *
	 * @return AbstractUpgrade
	 *
	 * @throws \InvalidArgumentException
	 */
	public function getUpgrade($versionId)
	{
		$versionId = intval($versionId);
		if (!$versionId)
		{
			throw new \InvalidArgumentException('No upgrade version ID specified.');
		}

		$upgrades = $this->getPossibleUpgradeFileNames();
		if (isset($upgrades[$versionId]))
		{
			require_once $upgrades[$versionId];
			$class = '\XF\Install\Upgrade\Version' . $versionId;
			return new $class($this->app);
		}

		throw new \InvalidArgumentException('Could not find the specified upgrade.');
	}

	public function getCurrentVersion()
	{
		if ($this->currentVersion === null)
		{
			$existingVersion = $this->db()->fetchOne("
				SELECT option_value
				FROM xf_option
				WHERE option_id = 'currentVersionId'
			");

			$this->currentVersion = $existingVersion ?: 0;
		}

		return $this->currentVersion;
	}

	public function getAddOnConflicts($fromVersion)
	{
		$conflicts = [];

		if ($fromVersion < 2000010)
		{
			$sm = $this->db()->getSchemaManager();

			if ($sm->tableExists('xf_forum_field'))
			{
				$conflicts[] = '[TH] Custom Fields';
			}
			if ($sm->tableExists('xf_widget'))
			{
				$conflicts[] = '[bd] Widget Framework';
			}
			if ($sm->columnExists('xf_conversation_message', 'like_users'))
			{
				$conflicts[] = 'Conversation Improvements by Xon';
			}
			if ($sm->columnExists('xf_thread', 'prefix_id', $prefixDef) && !empty($prefixDef['Type']))
			{
				if (!preg_match('#(int|integer)(\s|\(|$)#i', $prefixDef['Type']))
				{
					$conflicts[] = 'Multi Prefix';
				}
			}
		}

		return $conflicts;
	}

	public function getCliCommand()
	{
		return 'php cmd.php xf:install';
	}

	public function getDefaultSchemaErrors()
	{
		$ds = \XF::$DS;
		$hashesPath = \XF::getAddOnDirectory() . $ds . 'XF' . $ds . 'hashes.json';
		if (file_exists($hashesPath))
		{
			$hashes = json_decode(file_get_contents($hashesPath), true);
		}
		else
		{
			$hashes = null;
		}

		return $this->runSchemaCompare(
			'XF\Entity',
			\XF::getSourceDirectory() . $ds . 'XF' . $ds . 'Entity',
			$hashes
		);
	}

	public function runSchemaCompare($entityClassPrefix, $entityClassDir, ?array $fileLookup = null)
	{
		$db = $this->app->db();
		$tables = array_fill_keys($db->fetchAllColumn('SHOW TABLES'), true);
		$errors = [];

		$entityClasses = $this->_findSchemaClasses($entityClassPrefix, $entityClassDir, $fileLookup);
		foreach ($entityClasses AS $class)
		{
			if (!class_exists($class))
			{
				continue;
			}

			$reflection = new \ReflectionClass($class);
			if (!$reflection->isInstantiable() || !$reflection->isSubclassOf(Entity::class))
			{
				continue;
			}

			$entity = $this->app->em()->create($class);
			$structure = $entity->structure();

			if (!isset($tables[$structure->table]))
			{
				$errors[$structure->table] = "Table $structure->table missing.";
			}

			$columns = $db->fetchAllKeyed('
				SHOW COLUMNS FROM `' . $structure->table . '`
			', 'Field');

			foreach ($structure->columns AS $column => $definition)
			{
				if (!isset($columns[$column]))
				{
					$errors["$structure->table.$column"] = "Column $structure->table.$column missing.";
				}
			}
		}

		return $errors;
	}

	protected function _findSchemaClasses($classPrefix, $searchDir, ?array $fileLookup = null)
	{
		$searchDir = rtrim($searchDir, '/\\');
		$dir = opendir($searchDir);
		if (!$dir)
		{
			return [];
		}

		$output = [];
		while (($entry = readdir($dir)) !== false)
		{
			if ($entry == '.' || $entry == '..')
			{
				continue;
			}

			$fullPath = "$searchDir/$entry";

			if (is_dir($fullPath))
			{
				continue;
			}

			if ($fileLookup !== null)
			{
				$testFile = str_replace(\XF::getSourceDirectory(), 'src', $fullPath);
				if (!isset($fileLookup[$testFile]))
				{
					// this file doesn't exist any more - likely a left over from a previous version
					continue;
				}
			}

			if (preg_match('#^([a-z0-9_]+)\.php$#i', $entry, $match))
			{
				$output[] = $classPrefix . '\\' . $match[1];
			}
		}

		return $output;
	}

	public function assertValidUpgradeSteps(AbstractUpgrade $upgrade)
	{
		$reflection = new \ReflectionObject($upgrade);

		$stepsFound = [];

		foreach ($reflection->getMethods() AS $method)
		{
			if (preg_match('/^step[^0-9]/i', $method->name))
			{
				throw new \RuntimeException("Upgrade {$reflection->name} contains unexpected step, {$method->name}");
			}
			else if (preg_match('/^step([0-9]+)$/i', $method->name, $match))
			{
				// Don't need to worry about dupes -- that's a PHP parse error
				$stepsFound[$match[1]] = true;
			}
		}

		for ($i = 1; $stepsFound; $i++)
		{
			if (!isset($stepsFound[$i]))
			{
				throw new \RuntimeException("Upgrade {$reflection->name} could not find expected step number $i (later steps found)");
			}

			unset($stepsFound[$i]);
		}
	}

	public function isSignificantUpgrade()
	{
		$currentVersion = $this->getCurrentVersion();
		if ($currentVersion)
		{
			$diff = floor(\XF::$versionId / 10000) - floor($currentVersion / 10000);
			if ($diff == 0)
			{
				// upgrading in the same branch (1.3.0 -> 1.3.1 for example). Web upgrader should be fine in general
				return false;
			}
		}

		return true;
	}

	public function isCliRecommended()
	{
		if (!$this->isSignificantUpgrade())
		{
			return false;
		}

		$totals = $this->app->db()->fetchOne("
			SELECT data_value
			FROM xf_data_registry
			WHERE data_key IN ('boardTotals', 'forumStatistics')
			LIMIT 1
		");
		if (!$totals)
		{
			return false;
		}

		$totals = @unserialize($totals);
		if (!$totals)
		{
			return false;
		}

		if (!empty($totals['messages']) && $totals['messages'] >= 500000)
		{
			return true;
		}

		if (!empty($totals['users']) && $totals['users'] >= 50000)
		{
			return true;
		}

		return false;
	}

	public function translateLegacyConfig(array $config)
	{
		$new = Arr::arrayFilterKeys($config, [
			'db',
			'debug',
			'cookie',
			'enableMail',
			'enableMailQueue',
			'enableListeners',
			'globalSalt',
			'superAdmins',
			'internalDataPath',
			'externalDataPath',
			'externalDataUrl',
			'javaScriptUrl',
			'passwordIterations',
			'enableTemplateModificationCallbacks',
			'enableClickjackingProtection',
			'enableReverseTabnabbingProtection',
			'enableTfa',
			'maxImageResizePixelCount',
			'adminLogLength',
			'checkVersion',
			'chmodWritableValue',
		], true);

		unset($new['db']['adapter'], $new['db']['adapterNamespace']);

		if (isset($config['rebuildMaxExecution']))
		{
			$new['jobMaxRunTime'] = $config['rebuildMaxExecution'];
		}

		if (!isset($config['superAdmins']))
		{
			$new['superAdmins'] = '1';
		}

		return $new;
	}

	public function migrateLegacyConfigIfNeeded(&$written = false)
	{
		// load only the actual config values and filter down to the ones we want to keep
		$config = [];
		$container = $this->app->container();
		include $container['config.legacyFile'];

		$config = $this->translateLegacyConfig($config);
		$configPhp = $this->helper()->generateConfig($config);
		$configFile = $container['config.file'];

		if (!file_exists($configFile) && is_writable(dirname($configFile)))
		{
			try
			{
				file_put_contents($configFile, $configPhp);
				File::makeWritableByFtpUser($configFile);

				$written = true;
			}
			catch (\Exception $e)
			{
				$written = false;
			}
		}
		else
		{
			$written = false;
		}

		return $config;
	}

	public function renameLegacyConfigIfNeeded()
	{
		$legacyFile = $this->app->container('config.legacyFile');

		if (file_exists($legacyFile) && is_writable($legacyFile))
		{
			@rename($legacyFile, \XF::getRootDirectory() . '/library/xf1-config.php');
		}
	}

	public function getUpgradeJobs($immediate = true)
	{
		return $this->db()->fetchAllKeyed("
			SELECT *
			FROM xf_upgrade_job
			WHERE immediate = ?
		", 'unique_key', [$immediate ? 1 : 0]);
	}

	public function getExtraUpgradeJobsMap($immediate = true)
	{
		$extra = [];
		foreach ($this->getUpgradeJobs($immediate) AS $job)
		{
			$extra[] = [$job['execute_class'], unserialize($job['execute_data'])];
		}

		return $extra;
	}

	public function clearUpgradeJobs($immediate = true)
	{
		return $this->db()->delete('xf_upgrade_job', 'immediate = ?', [$immediate ? 1 : 0]);
	}

	public function getPostUpgradeJobs()
	{
		return $this->getUpgradeJobs(true);
	}

	public function enqueuePostUpgradeJobs($clear = true)
	{
		$jobs = $this->getUpgradeJobs(false);
		$jobManager = $this->app->jobManager();

		foreach ($jobs AS $job)
		{
			$params = unserialize($job['execute_data']);
			$jobManager->enqueueUnique($job['unique_key'], $job['execute_class'], $params, false);
		}

		if ($jobs && $clear)
		{
			$this->clearUpgradeJobs(false);
		}
	}

	public function enqueueFileCleanUp(): void
	{
		$this->app->jobManager()->enqueueUnique('xfFileCleanUp' . \XF::$versionId, FileCleanUp::class, [
			'addon_id' => 'XF',
		], false);
	}

	public function enqueueUpgradeCheck()
	{
		$this->app->jobManager()->enqueueUnique('xfUpgradeCheck', UpgradeCheck::class, [], false);
	}

	public function enqueueStatsCollection()
	{
		$collectStatsRepo = $this->app->repository(CollectStatsRepository::class);

		if ($collectStatsRepo->isEnabled())
		{
			$this->app->jobManager()->enqueueUnique('xfCollectStats', CollectStats::class, [], false);
		}
	}

	public function clearRegistryCache(): void
	{
		/** @var AdapterInterface|null $cache */
		$cache = $this->app->cache('registry', true, false, true);
		if (!$cache)
		{
			return;
		}

		$cache->clear();
	}
}
