<?php

namespace XF\Job;

use XF\AddOn\AddOn;
use XF\Service\AddOnArchive\ExtractorService;
use XF\Timer;
use XF\Util\File;
use XF\Util\Php;

use function array_slice, strval;

class AddOnInstallBatch extends AbstractJob
{
	protected $defaultData = [
		'batch_id' => 0,

		// skip hash checks and overwrite all files
		'force_overwrite' => false,

		'addon_ids' => null,

		'current_addon_id' => null,
		'current_data' => [],
		'current_step' => null,
		'current_step_params' => [],
		'current_ticks' => 0,

		'results' => [],
	];

	/**
	 * @var \XF\Entity\AddOnInstallBatch
	 */
	protected $batch;

	/**
	 * @var AddOn
	 */
	protected $currentExistingAddOn;

	public function run($maxRunTime)
	{
		$timer = new Timer($maxRunTime);

		// make sure any errors get logged here
		$this->app->error()->setIgnorePendingUpgrade(true);

		/** @var \XF\Entity\AddOnInstallBatch $batch */
		$batch = $this->app->em()->find(\XF\Entity\AddOnInstallBatch::class, $this->data['batch_id']);
		if (!$batch)
		{
			return $this->complete();
		}

		$this->batch = $batch;

		if ($this->data['addon_ids'] === null)
		{
			$this->data['addon_ids'] = array_keys($batch->addon_ids);
		}

		if (!$this->data['addon_ids'] && !$this->data['current_addon_id'])
		{
			$this->finalizeBatch();
			return $this->complete();
		}

		if (!$this->data['current_addon_id'])
		{
			$this->setupNextAddOn();
		}

		$this->currentExistingAddOn = $this->app->addOnManager()->getById($this->data['current_addon_id']);

		$this->data['current_ticks']++;

		try
		{
			switch ($this->data['current_step'])
			{
				case 'init':
					$this->stepInit($timer);
					break;

				case 'copy':
					$this->stepCopy($timer);
					break;

				case 'pre_action':
					$this->stepPreAction($timer);
					break;

				case 'action':
					$this->stepAction($timer);
					break;

				case 'data':
					$this->stepData($timer);
					break;

				case 'finalize':
					$this->stepFinalize($timer);
					break;

				default:
					throw new \LogicException("Unknown current step: " . $this->data['current_step']);
			}
		}
		catch (\Exception $e)
		{
			\XF::logException($e, true, "Batch install error: ");

			$this->logErrorAndReset("Exception: " . $e->getMessage());
		}

		if (!$this->data['current_addon_id'] || !$this->data['current_step'])
		{
			$this->setupNextAddOn();

			if (!$this->data['current_addon_id'])
			{
				$this->finalizeBatch();
				return $this->complete();
			}
		}

		return $this->resume();
	}

	protected function stepInit(Timer $timer)
	{
		$addOnId = $this->data['current_addon_id'];
		$this->data['results'][$addOnId] = [
			'status' => null,
			'error' => null,
			'action' => null,
			'old_version' => $this->currentExistingAddOn ? $this->currentExistingAddOn->version_string : null,
		];

		$zipFile = File::copyAbstractedPathToTempFile($this->batch->getAbstractedAddOnBatchPath($addOnId), false);
		$this->data['current_data']['file'] = $zipFile;
		$this->data['current_data']['file_temp'] = true;

		/** @var ExtractorService $extractor */
		$extractor = $this->app->service(ExtractorService::class, $addOnId, $zipFile);
		if ($this->currentExistingAddOn && !$this->data['force_overwrite'])
		{
			$existingHashes = $this->currentExistingAddOn->getHashes() ?? [];
			$hashChanges = $extractor->compareHashes($existingHashes);
		}
		else
		{
			$hashChanges = null;
		}

		$this->data['current_data']['hash_changes'] = $hashChanges;

		if (!$extractor->checkWritable($hashChanges, $failures))
		{
			$failedSubset = array_slice($failures, 0, 5);
			\XF::logError("Failed to write files for $addOnId action, including " . implode(', ', $failedSubset));

			$this->logErrorAndReset("Not all files are writable (example: $failures[0]). Cannot continue.");
		}

		$this->changeStep('copy');
	}

	protected function stepCopy(Timer $timer)
	{
		$params = array_replace([
			'start' => 0,
		], $this->data['current_step_params']);

		$addOnId = $this->data['current_addon_id'];
		$zipFile = $this->data['current_data']['file'];
		$hashChanges = $this->data['current_data']['hash_changes'];

		/** @var ExtractorService $extractor */
		$extractor = $this->app->service(ExtractorService::class, $addOnId, $zipFile);
		$result = $extractor->copyFiles($hashChanges, $params['start'], $timer);

		switch ($result['status'])
		{
			case 'error':
				$this->logErrorAndReset($result['error']);
				break;

			case 'incomplete':
				$params['start'] = $result['last'] + 1;
				$this->data['current_step_params'] = $params;
				break;

			case 'complete':
				Php::resetOpcache();
				$this->changeStep('pre_action');
				break;

			default:
				throw new \LogicException("Unknown result from copy '$result[status]'");
		}
	}

	protected function stepPreAction(Timer $timer)
	{
		if (!$this->currentExistingAddOn)
		{
			throw new \LogicException("Add-on should be available");
		}

		$addOn = $this->currentExistingAddOn;
		$title = $addOn->title;

		$action = null;
		if ($addOn->canInstall())
		{
			// do this first, technically, getInstalledAddOn can return something but where we haven't completed
			// the install steps yet
			$action = 'install';
		}
		else if ($addOn->getInstalledAddOn())
		{
			if ($addOn->canUpgrade())
			{
				$action = 'upgrade';
			}
			else if ($addOn->canRebuild())
			{
				$action = 'rebuild';
			}
		}

		if (!$action)
		{
			$this->logErrorAndReset("No completable action available.");
			return;
		}

		$addOn->checkRequirements($errors, $warnings);
		if ($errors)
		{
			$this->logErrorAndReset(
				\XF::phrase('following_errors_were_found_when_checking_add_on_x_meets_requirements', ['title' => $title])
				. " · " . implode(" · ", $errors)
			);
			return;
		}

		// Note: this is intentionally bypassed because the hash comparison method means that we only update
		// changed files. If someone has edited a file that the upgrade doesn't change, that will be maintained
		// and thus could cause a health check failure. This can be revisited if needed.
		/*if ($addOn->getHashes())
		{
			$addOn->passesHealthCheck($missing, $inconsistent);
			if ($missing || $inconsistent)
			{
				$this->logErrorAndReset(
					\XF::phrase('cannot_proceed_with_installation_upgrade_of_x_because_files_not_uploaded', ['addOnTitle' => $title])
				);
				return;
			}
		}*/

		$method = 'pre' . ucfirst($action);
		$addOn->{$method}();

		$this->data['current_data']['action'] = $action;

		$this->changeStep('action');
	}

	protected function stepAction(Timer $timer)
	{
		if (empty($this->data['current_data']['action']))
		{
			throw new \LogicException("No action available");
		}
		if (!$this->currentExistingAddOn)
		{
			throw new \LogicException("Add-on should be available");
		}

		\XF::app()->error()->setIgnorePendingUpgrade(true);

		$params = array_replace([
			'step_params' => [],
		], $this->data['current_step_params']);

		$setup = $this->currentExistingAddOn->getSetup();
		if ($setup)
		{
			$action = $this->data['current_data']['action'];

			$setup->prepareForAction($action);
			switch ($action)
			{
				case 'install':
					$result = $setup->install($params['step_params']);
					break;

				case 'upgrade':
					$result = $setup->upgrade($params['step_params']);
					break;

				default:
					$result = null;
			}
		}
		else
		{
			$result = null;
		}

		if (!$result)
		{
			$this->changeStep('data');
			return;
		}

		$params['step_params'] = $result->params;
		$params['step_params']['step'] = $result->step;
		if ($result->version)
		{
			$params['step_params']['version_id'] = $result->version;
		}
		$this->data['current_step_params'] = $params;
	}

	protected function stepData(Timer $timer)
	{
		$params = array_replace([
			'started' => false,
			'data' => null,
			'message' => null,
		], $this->data['current_step_params']);

		if (!$params['started'])
		{
			$params['data'] = ['addon_id' => $this->data['current_addon_id']];
			$params['started'] = true;
		}
		$job = $this->app->job(AddOnData::class, 0, $params['data']);

		$result = $job->run($timer->remaining() ?: 2);
		if ($result->result === JobResult::RESULT_COMPLETED)
		{
			$this->changeStep('finalize');
		}
		else
		{
			$params['data'] = $result->data;
			$params['message'] = $result->statusMessage;
			$this->data['current_step_params'] = $params;
		}
	}

	protected function stepFinalize(Timer $timer)
	{
		if (empty($this->data['current_data']['action']))
		{
			throw new \LogicException("No action available");
		}
		if (!$this->currentExistingAddOn)
		{
			throw new \LogicException("Add-on should be available");
		}

		$addOn = $this->currentExistingAddOn;

		$installed = $addOn->getInstalledAddOn();
		if ($installed)
		{
			// this is a sanity check, it shouldn't happen
			$installed->is_processing = false;
			$installed->save();
		}

		$null = [];

		// other actions shouldn't really happen here
		switch ($this->data['current_data']['action'])
		{
			case 'upgrade':
				$addOn->postUpgrade($null);
				break;

			case 'install':
				$addOn->postInstall($null);
				break;

			case 'rebuild':
				$addOn->postRebuild();
				break;
		}

		$addOnId = $this->data['current_addon_id'];
		$this->data['results'][$addOnId]['status'] = 'success';
		$this->data['results'][$addOnId]['action'] = $this->data['current_data']['action'];

		$this->changeStep(''); // this will setup the next add-on, but keep the current_addon_id for now
	}

	protected function setupNextAddOn()
	{
		if ($this->data['current_addon_id'])
		{
			$currentData = $this->data['current_data'];
			if (!empty($currentData['file']) && !empty($currentData['file_temp']))
			{
				@unlink($currentData['file']);
			}
		}

		$this->data['current_addon_id'] = null;
		$this->data['current_data'] = [];
		$this->data['current_step'] = null;
		$this->data['current_step_params'] = [];
		$this->data['current_ticks'] = 0;

		if ($this->data['addon_ids'])
		{
			$addOnId = array_shift($this->data['addon_ids']);

			$this->data['current_addon_id'] = $addOnId;
			$this->changeStep('init');
		}
	}

	protected function changeStep($name, array $params = [])
	{
		$this->data['current_step'] = $name;
		$this->data['current_step_params'] = $params;
	}


	protected function logErrorAndReset($error)
	{
		$addOnId = $this->data['current_addon_id'];
		if ($addOnId)
		{
			$this->data['results'][$addOnId]['status'] = 'error';
			$this->data['results'][$addOnId]['error'] = strval($error);
		}

		$this->setupNextAddOn();
	}

	protected function finalizeBatch()
	{
		$batch = $this->batch;
		$batch->complete_date = time();
		$batch->results = $this->data['results'];
		$batch->save();
	}

	public function getStatusMessage()
	{
		$addOnId = $this->data['current_addon_id'];

		if (!$addOnId || !$this->data['current_step'])
		{
			return \XF::phrase('processing...');
		}

		switch ($this->data['current_step'])
		{
			case 'init': $message = \XF::phrase('copying_files...'); break;
			case 'copy': $message = \XF::phrase('copying_files...'); break;
			case 'data': $message = \XF::phrase('importing...'); break;
			case 'finalize': $message = \XF::phrase('finalizing...'); break;

			case 'pre_action':
			case 'action':
				if (empty($this->data['current_data']['action']))
				{
					$message = \XF::phrase('running_setup...');
				}
				else
				{
					switch ($this->data['current_data']['action'])
					{
						case 'install': $message = \XF::phrase('installing...'); break;
						case 'upgrade': $message = \XF::phrase('upgrading...'); break;
						case 'rebuild': $message = \XF::phrase('rebuilding...'); break;
						default: $message = \XF::phrase('running_setup...');
					}
				}
				break;


			default: $message = $this->data['current_step'];
		}

		return "$addOnId - $message " . str_repeat('. ', $this->data['current_ticks']);
	}

	public function canCancel()
	{
		return false;
	}

	public function canTriggerByChoice()
	{
		return false;
	}
}
