<?php

/**
 * This file is part of the Nette Tester.
 * Copyright (c) 2009 David Grudl (https://davidgrudl.com)
 */

declare(strict_types=1);

namespace Tester\Runner;

use Tester\CodeCoverage;
use Tester\Dumper;
use Tester\Environment;
use Tester\Helpers;


/**
 * CLI Tester.
 */
class CliTester
{
	/** @var array */
	private $options;

	/** @var PhpInterpreter */
	private $interpreter;

	/** @var bool */
	private $debugMode = true;


	public function run(): ?int
	{
		Environment::setupColors();
		$this->setupErrors();

		ob_start();
		$cmd = $this->loadOptions();

		$this->debugMode = (bool) $this->options['--debug'];
		if (isset($this->options['--colors'])) {
			Environment::$useColors = (bool) $this->options['--colors'];
		} elseif (in_array($this->options['-o'], ['tap', 'junit'], true)) {
			Environment::$useColors = false;
		}

		if ($cmd->isEmpty() || $this->options['--help']) {
			$cmd->help();
			return null;
		}

		$this->createPhpInterpreter();

		if ($this->options['--info']) {
			$job = new Job(new Test(__DIR__ . '/info.php'), $this->interpreter);
			$job->run();
			echo $job->getTest()->stdout;
			return null;
		}

		$runner = $this->createRunner();
		$runner->setEnvironmentVariable(Environment::RUNNER, '1');
		$runner->setEnvironmentVariable(Environment::COLORS, (string) (int) Environment::$useColors);

		if ($this->options['--coverage']) {
			$coverageFile = $this->prepareCodeCoverage($runner);
		}

		if ($this->options['-o'] !== null) {
			ob_clean();
		}
		ob_end_flush();

		if ($this->options['--watch']) {
			$this->watch($runner);
			return null;
		}

		$result = $runner->run();

		if (isset($coverageFile) && preg_match('#\.(?:html?|xml)$#D', $coverageFile)) {
			$this->finishCodeCoverage($coverageFile);
		}

		return $result ? 0 : 1;
	}


	private function loadOptions(): CommandLine
	{
		echo <<<'XX'
 _____ ___  ___ _____ ___  ___
|_   _/ __)( __/_   _/ __)| _ )
  |_| \___ /___) |_| \___ |_|_\  v2.3.4


XX;

		$cmd = new CommandLine(<<<'XX'
Usage:
    tester [options] [<test file> | <directory>]...

Options:
    -p <path>                    Specify PHP interpreter to run (default: php).
    -c <path>                    Look for php.ini file (or look in directory) <path>.
    -C                           Use system-wide php.ini.
    -l | --log <path>            Write log to file <path>.
    -d <key=value>...            Define INI entry 'key' with value 'value'.
    -s                           Show information about skipped tests.
    --stop-on-fail               Stop execution upon the first failure.
    -j <num>                     Run <num> jobs in parallel (default: 8).
    -o <console|tap|junit|none>  Specify output format.
    -w | --watch <path>          Watch directory.
    -i | --info                  Show tests environment info and exit.
    --setup <path>               Script for runner setup.
    --temp <path>                Path to temporary directory. Default by sys_get_temp_dir().
    --colors [1|0]               Enable or disable colors.
    --coverage <path>            Generate code coverage report to file.
    --coverage-src <path>        Path to source code.
    -h | --help                  This help.

XX
		, [
			'-c' => [CommandLine::REALPATH => true],
			'--watch' => [CommandLine::REPEATABLE => true, CommandLine::REALPATH => true],
			'--setup' => [CommandLine::REALPATH => true],
			'--temp' => [CommandLine::REALPATH => true],
			'paths' => [CommandLine::REPEATABLE => true, CommandLine::VALUE => getcwd()],
			'--debug' => [],
			'--cider' => [],
			'--coverage-src' => [CommandLine::REALPATH => true, CommandLine::REPEATABLE => true],
		]);

		if (isset($_SERVER['argv'])) {
			if ($tmp = array_search('-log', $_SERVER['argv'], true)) {
				$_SERVER['argv'][$tmp] = '--log';
			}

			if ($tmp = array_search('--tap', $_SERVER['argv'], true)) {
				unset($_SERVER['argv'][$tmp]);
				$_SERVER['argv'] = array_merge($_SERVER['argv'], ['-o', 'tap']);
			}
		}

		$this->options = $cmd->parse();
		if ($this->options['--temp'] === null) {
			if (($temp = sys_get_temp_dir()) === '') {
				echo "Note: System temporary directory is not set.\n";
			} elseif (($real = realpath($temp)) === false) {
				echo "Note: System temporary directory '$temp' does not exist.\n";
			} else {
				$this->options['--temp'] = rtrim($real, DIRECTORY_SEPARATOR);
			}
		}

		return $cmd;
	}


	private function createPhpInterpreter(): void
	{
		$args = $this->options['-C'] ? [] : ['-n'];
		if ($this->options['-c']) {
			array_push($args, '-c', $this->options['-c']);
		} elseif (!$this->options['--info'] && !$this->options['-C']) {
			echo "Note: No php.ini is used.\n";
		}

		if (in_array($this->options['-o'], ['tap', 'junit'], true)) {
			array_push($args, '-d', 'html_errors=off');
		}

		foreach ($this->options['-d'] as $item) {
			array_push($args, '-d', $item);
		}

		$this->interpreter = new PhpInterpreter($this->options['-p'], $args);

		if ($error = $this->interpreter->getStartupError()) {
			echo Dumper::color('red', "PHP startup error: $error") . "\n";
		}
	}


	private function createRunner(): Runner
	{
		$runner = new Runner($this->interpreter);
		$runner->paths = $this->options['paths'];
		$runner->threadCount = max(1, (int) $this->options['-j']);
		$runner->stopOnFail = $this->options['--stop-on-fail'];

		if ($this->options['--temp'] !== null) {
			$runner->setTempDirectory($this->options['--temp']);
		}

		switch ($this->options['-o']) {
			case 'none':
				break;
			case 'tap':
				$runner->outputHandlers[] = new Output\TapPrinter;
				break;
			case 'junit':
				$runner->outputHandlers[] = new Output\JUnitPrinter;
				break;
			default:
				$runner->outputHandlers[] = new Output\ConsolePrinter(
					$runner,
					(bool) $this->options['-s'],
					'php://output',
					(bool) $this->options['--cider']
				);
		}

		if ($this->options['--log']) {
			echo "Log: {$this->options['--log']}\n";
			$runner->outputHandlers[] = new Output\Logger($runner, $this->options['--log']);
		}

		if ($this->options['--setup']) {
			(function () use ($runner): void {
				require func_get_arg(0);
			})($this->options['--setup']);
		}
		return $runner;
	}


	private function prepareCodeCoverage(Runner $runner): string
	{
		$engines = $this->interpreter->getCodeCoverageEngines();
		if (count($engines) < 1) {
			throw new \Exception("Code coverage functionality requires Xdebug or PCOV extension or PHPDBG SAPI (used {$this->interpreter->getCommandLine()})");
		}

		file_put_contents($this->options['--coverage'], '');
		$file = realpath($this->options['--coverage']);

		$runner->setEnvironmentVariable(Environment::COVERAGE, $file);
		$runner->setEnvironmentVariable(Environment::COVERAGE_ENGINE, $engine = reset($engines));

		if ($engine === CodeCoverage\Collector::ENGINE_PCOV && count($this->options['--coverage-src'])) {
			$runner->addPhpIniOption('pcov.directory', Helpers::findCommonDirectory($this->options['--coverage-src']));
		}

		echo "Code coverage by $engine: $file\n";
		return $file;
	}


	private function finishCodeCoverage(string $file): void
	{
		if (!in_array($this->options['-o'], ['none', 'tap', 'junit'], true)) {
			echo 'Generating code coverage report... ';
		}
		if (filesize($file) === 0) {
			echo 'failed. Coverage file is empty. Do you call Tester\Environment::setup() in tests?';
			return;
		}
		$generator = pathinfo($file, PATHINFO_EXTENSION) === 'xml'
			? new CodeCoverage\Generators\CloverXMLGenerator($file, $this->options['--coverage-src'])
			: new CodeCoverage\Generators\HtmlGenerator($file, $this->options['--coverage-src']);
		$generator->render($file);
		echo round($generator->getCoveredPercent()) . "% covered\n";
	}


	private function watch(Runner $runner): void
	{
		$prev = [];
		$counter = 0;
		while (true) {
			$state = [];
			foreach ($this->options['--watch'] as $directory) {
				foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory)) as $file) {
					if (substr($file->getExtension(), 0, 3) === 'php' && substr($file->getBasename(), 0, 1) !== '.') {
						$state[(string) $file] = @filemtime((string) $file); // @ file could be deleted in the meantime
					}
				}
			}
			if ($state !== $prev) {
				$prev = $state;
				try {
					$runner->run();
				} catch (\ErrorException $e) {
					$this->displayException($e);
				}
				echo "\n";
				$time = time();
			}

			$idle = time() - $time;
			if ($idle >= 60 * 60) {
				$idle = 'long time';
			} elseif ($idle >= 60) {
				$idle = round($idle / 60) . ' min';
			} else {
				$idle .= ' sec';
			}
			echo 'Watching ' . implode(', ', $this->options['--watch']) . " (idle for $idle) " . str_repeat('.', ++$counter % 5) . "    \r";
			sleep(2);
		}
	}


	private function setupErrors(): void
	{
		error_reporting(E_ALL);
		ini_set('html_errors', '0');

		set_error_handler(function (int $severity, string $message, string $file, int $line) {
			if (($severity & error_reporting()) === $severity) {
				throw new \ErrorException($message, 0, $severity, $file, $line);
			}
			return false;
		});

		set_exception_handler(function (\Throwable $e) {
			$this->displayException($e);
			exit(2);
		});
	}


	private function displayException(\Throwable $e): void
	{
		echo "\n";
		echo $this->debugMode
			? Dumper::dumpException($e)
			: Dumper::color('white/red', 'Error: ' . $e->getMessage());
		echo "\n";
	}
}
