<?php

namespace XF\Service\AddOn;

use XF\AddOn\AddOn;
use XF\App;
use XF\PrintableException;
use XF\Service\AbstractService;
use XF\Util\File;

use function array_key_exists, is_array, is_scalar, strlen;

class ReleaseBuilderService extends AbstractService
{
	/**
	 * @var AddOn
	 */
	protected $addOn;

	protected $addOnRoot;
	protected $buildRoot;
	protected $uploadRoot;
	protected $addOnBase;
	protected $tempFile;

	/**
	 * @var \RecursiveIteratorIterator|\SplFileInfo[]
	 */
	protected $localFs;

	/**
	 * @var \ZipArchive
	 */
	protected $zipArchive;

	protected $generateHashes = true;
	protected $hashesGenerated;

	protected $skipBuildTasks = false;
	protected $buildTasksComplete;

	public function __construct(App $app, AddOn $addOn)
	{
		parent::__construct($app);

		$this->addOn = $addOn;

		$this->prepareDirectories();
		$this->prepareFilesToCopy();
		$this->prepareFsAdapters();
	}

	public function setGenerateHashes($generate)
	{
		$this->generateHashes = $generate;
	}

	public function setSkipBuildTasks($skip)
	{
		$this->skipBuildTasks = $skip;
	}

	protected function prepareDirectories()
	{
		$ds = \XF::$DS;

		$addOn = $this->addOn;
		$addOnDir = $addOn->getAddOnDirectory();
		$buildDir = $addOn->getBuildDirectory();

		$uploadDir = $buildDir . $ds . 'upload';
		$addOnBase = $uploadDir . $ds . 'src' . $ds . 'addons' . $ds . $addOn->prepareAddOnIdForPath();

		if (file_exists($buildDir))
		{
			File::deleteDirectory($buildDir);
		}

		File::createDirectory($buildDir, false);
		File::createDirectory($uploadDir, false);
		File::createDirectory($addOnBase, false);

		$this->addOnRoot = $addOnDir;
		$this->buildRoot = $buildDir;
		$this->uploadRoot = $uploadDir;
		$this->addOnBase = $addOnBase;
	}

	protected function prepareFilesToCopy()
	{
		$addOn = $this->addOn;
		$addOnRoot = $this->addOnRoot;
		$uploadRoot = $this->uploadRoot;
		$addOnBase = $this->addOnBase;

		$ds = \XF::$DS;

		$exclude = [];

		$files = File::getRecursiveDirectoryIterator($addOnRoot);
		foreach ($files AS $file)
		{
			$copyRoot = $addOnBase;

			$path = File::stripRootPathPrefix($file->getPathname(), $addOnRoot);
			if ($this->isPartOfExcludedDirectory($path))
			{
				if (strpos($path, '_no_upload') !== 0)
				{
					continue;
				}

				$path = File::stripRootPathPrefix($file->getPathname(), $addOnRoot . $ds . '_no_upload');
				if ($file->isDir() && $path == '_no_upload')
				{
					continue;
				}

				$copyRoot = $uploadRoot . $ds . '..'; // These need copying, but to a different path (outside upload)
			}

			if ($this->isExcludedFileName($file->getFilename()))
			{
				$exclude[$file->getPathname()] = true;
				continue;
			}

			if (array_key_exists($file->getPath(), $exclude))
			{
				$exclude[$file->getPathname()] = true;
				continue;
			}

			if (!$file->isDir())
			{
				if ($path === 'build.json')
				{
					continue;
				}

				File::copyFile($file->getPathname(), $copyRoot . $ds . $path, false);
			}
		}

		$rootPath = \XF::getRootDirectory();
		$filesRoot = $addOn->getFilesDirectory();

		$additionalFiles = $addOn->additional_files;
		foreach ((array) $additionalFiles AS $additionalFile)
		{
			$filePath = $filesRoot . $ds . $additionalFile;
			if (file_exists($filePath))
			{
				$root = $filesRoot;
			}
			else
			{
				$filePath = $rootPath . $ds . $additionalFile;
				if (!file_exists($filePath))
				{
					continue;
				}
				$root = $rootPath;
			}

			if (is_dir($filePath))
			{
				$files = File::getRecursiveDirectoryIterator($filePath);
				foreach ($files AS $file)
				{
					if ($this->isExcludedFileName($file->getFilename()))
					{
						$exclude[$file->getPathname()] = true;
						continue;
					}

					if (array_key_exists($file->getPath(), $exclude))
					{
						$exclude[$file->getPathname()] = true;
						continue;
					}

					$stdPath = File::stripRootPathPrefix($file->getPathname(), $root);
					if (!$file->isDir())
					{
						File::copyFile($file->getPathname(), $uploadRoot . $ds . $stdPath, false);
					}
				}
			}
			else
			{
				$stdPath = File::stripRootPathPrefix($filePath, $root);
				File::copyFile($filePath, $uploadRoot . $ds . $stdPath, false);
			}
		}
	}

	protected function prepareFsAdapters()
	{
		$this->localFs = File::getRecursiveDirectoryIterator($this->buildRoot);
		$this->tempFile = File::getTempFile();

		$zipArchive = new \ZipArchive();
		if (($error = $zipArchive->open($this->tempFile, \ZipArchive::OVERWRITE)) !== true)
		{
			throw new \RuntimeException(sprintf("Unable to create zip archive, error %d", $error));
		}
		$this->zipArchive = $zipArchive;
	}

	/**
	 * @return array|null|string
	 * @throws \ErrorException
	 */
	public function generateHashes()
	{
		if ($this->hashesGenerated || !$this->generateHashes)
		{
			return null;
		}

		$ds = \XF::$DS;

		/** @var HashGeneratorService $hashGenerator */
		$hashGenerator = $this->service(
			HashGeneratorService::class,
			$this->addOn,
			$this->uploadRoot,
			$this->addOnBase . $ds . 'hashes.json'
		);

		$output = $hashGenerator->generate();

		$this->hashesGenerated = true;

		return $output;
	}

	public function validateComposerPath()
	{
		$addOn = $this->addOn;
		$autoloadPath = $addOn->composer_autoload;

		if (!$autoloadPath)
		{
			return;
		}

		$addOnAutoload = $this->addOnBase . \XF::$DS . $autoloadPath;

		if (!file_exists($addOnAutoload . \XF::$DS . 'installed.json'))
		{
			File::deleteDirectory($this->buildRoot);
			throw new PrintableException(
				"Missing installed.json at " . File::stripRootPathPrefix($addOnAutoload) . ". This may not be a valid composer directory."
			);
		}
	}

	/**
	 * @throws PrintableException
	 */
	public function performBuildTasks()
	{
		$addOn = $this->addOn;
		$buildJsonPath = $addOn->getBuildJsonPath();

		if ($this->buildTasksComplete || $this->skipBuildTasks || !file_exists($buildJsonPath))
		{
			return;
		}

		if (!$this->testBuildJson($error))
		{
			File::deleteDirectory($this->buildRoot);
			throw new PrintableException('Cannot build add-on due to build.json error' . ($error ? ': ' . $error : '') . '.');
		}

		$buildJson = $addOn->getBuildJson();

		$this->minifyJs($buildJson['minify']);
		$this->rollupJs($buildJson['rollup']);
		$this->execCmds($buildJson['exec']);

		$this->buildTasksComplete = true;
	}

	protected function testBuildJson(&$error = null)
	{
		$addOn = $this->addOn;

		$baseBuildJson = @json_decode(file_get_contents($addOn->getBuildJsonPath()), true);
		if (!is_array($baseBuildJson))
		{
			$error = json_last_error_msg();
			return false;
		}

		return true;
	}

	/**
	 * @param $minify
	 *
	 * @throws PrintableException
	 */
	protected function minifyJs($minify)
	{
		if (!$minify)
		{
			return;
		}

		$uploadRoot = $this->uploadRoot;
		$ds = \XF::$DS;

		if (!is_array($minify) && $minify === '*')
		{
			$minify = [];

			$iterator = File::getRecursiveDirectoryIterator($uploadRoot . $ds . 'js');
			foreach ($iterator AS $file)
			{
				if ($file->isDir())
				{
					continue;
				}

				$fileName = $file->getBasename();

				if (strpos($fileName, '.js') === false || strpos($fileName, '.min.js') !== false)
				{
					continue;
				}
				$minify[] = str_replace($uploadRoot . $ds, '', $file->getPathname());
			}
		}

		foreach ($minify AS $file)
		{
			$minifier = $this->service(JsMinifierService::class, $uploadRoot . $ds . $file);

			try
			{
				$minifier->minify();
			}
			catch (\ErrorException $e)
			{
				File::deleteDirectory($this->buildRoot);
				throw new PrintableException('Unexpected error while minifying JS: ' . $e->getMessage());
			}
		}
	}

	protected function rollupJs(array $rollup)
	{
		if (!$rollup)
		{
			return;
		}

		foreach ($rollup AS $rollupPath => $files)
		{
			$output = '';
			foreach ($files AS $file)
			{
				$output .= file_get_contents($this->uploadRoot . \XF::$DS . $file);
				$output .= "\n\n";
			}
			File::writeFile($this->uploadRoot . \XF::$DS . $rollupPath, trim($output), false);
		}
	}

	protected function execCmds(array $exec)
	{
		if (!$exec)
		{
			return;
		}

		$addOn = $this->addOn;

		foreach ($exec AS $cmd)
		{
			$cmd = preg_replace_callback('/({([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)})/', function ($match) use ($addOn)
			{
				$placeholder = $match[1];
				$property = $match[2];

				$value = $addOn->{$property};

				if (!$value || !is_scalar($value))
				{
					return $placeholder;
				}

				return escapeshellarg($value);
			}, $cmd);

			chdir($this->addOnRoot);
			passthru($cmd);
		}
	}

	/**
	 * @return bool
	 *
	 * @throws \ErrorException
	 * @throws PrintableException
	 */
	public function build()
	{
		$this->validateComposerPath();
		$this->performBuildTasks();
		$this->generateHashes();

		$localFs = $this->localFs;
		$zipArchive = $this->zipArchive;

		// NOTE: any files skipped by generateHashes() won't appear in this loop...

		foreach ($localFs AS $file)
		{
			// skip hidden dot files, e.g. .DS_Store, .gitignore etc.
			if ($this->isExcludedFileName($file->getBasename()))
			{
				continue;
			}

			$localName = str_replace('\\', '/', substr($file->getPathname(), strlen($this->buildRoot) + 1));

			if ($file->isDir())
			{
				$localName .= '/';
				$zipArchive->addEmptyDir($localName);
				$perm = 040755 << 16; // dir: 0755
			}
			else
			{
				$zipArchive->addFile($file->getPathname(), $localName);
				$perm = 0100644 << 16; // file: 0644
			}

			if (method_exists($zipArchive, 'setExternalAttributesName'))
			{
				$zipArchive->setExternalAttributesName($localName, \ZipArchive::OPSYS_UNIX, $perm);
			}
		}

		if (!$zipArchive->close())
		{
			File::deleteDirectory($this->buildRoot);
			throw new \ErrorException($zipArchive->getStatusString());
		}

		return true;
	}

	public function finalizeRelease()
	{
		$releasePath = $this->addOn->getReleasePath();

		File::createDirectory(dirname($releasePath), false);
		File::renameFile($this->tempFile, $releasePath, false);

		File::deleteDirectory($this->buildRoot);
	}

	protected function isPartOfExcludedDirectory($path)
	{
		foreach ($this->getExcludedDirectories() AS $dir)
		{
			if (strpos($path, $dir) === 0)
			{
				return true;
			}
		}
		return false;
	}

	protected function getExcludedDirectories()
	{
		return [
			'_build',
			'_files',
			'_no_upload',
			'_output',
			'_releases',
			'_stubs',
			'.git',
			'.svn',
		];
	}

	protected function isExcludedFileName($fileName)
	{
		if ($fileName === '' || $fileName === false || $fileName === null)
		{
			return true;
		}

		if ($fileName[0] == '.' && $fileName != '.htaccess')
		{
			return true;
		}

		return false;
	}
}
