<?php

declare(strict_types=1);

namespace Codeception\Task\Merger;

use Codeception\Task\Exception\KeyNotFoundException;
use Codeception\Task\Exception\XPathExpressionException;
use DOMAttr;
use DOMDocument;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use Robo\Exception\TaskException;
use RuntimeException;

/**
 * Generate common HTML report
 * Class MergeHTMLReportsTask
 * @author Kerimov Asif
 *
 * @see \Tests\Codeception\Task\Merger\HtmlReportMergerTest
 */
class HtmlReportMerger extends AbstractMerger
{
    /** @var string[] */
    protected array $src = [];

    protected string $dst = '';

    /**
     * @var int|float
     */
    protected $countSuccess = 0;

    /**
     * @var int|float
     */
    protected $countFailed = 0;

    /**
     * @var int|float
     */
    protected $countSkipped = 0;

    /**
     * @var int|float
     */
    protected $countIncomplete = 0;

    protected bool $previousLibXmlUseErrors = false;

    protected bool $maxTime = false;

    protected array $executionTime = [];

    /**
     * @var string|float
     */
    private $executionTimeSum = 0;


    public function maxTime(): void
    {
        $this->maxTime = true;
    }

    /**
     * HtmlReportMerger constructor.
     *
     * @param string[] $src - array of source reports
     */
    public function __construct(array $src = [])
    {
        $this->src = $src;
    }

    /**
     * @param string[]|string $fileName - a single report file or array of report files
     * @return $this|HtmlReportMerger
     */
    public function from($fileName): self
    {
        if (is_array($fileName)) {
            $this->src = array_merge($fileName, $this->src);
        } else {
            $this->src[] = $fileName;
        }

        return $this;
    }

    /**
     * @return $this|HtmlReportMerger
     */
    public function into(string $fileName): self
    {
        $this->dst = $fileName;
        return $this;
    }

    public function run(): void
    {
        //save initial statament and switch on use_internal_errors mode
        $this->previousLibXmlUseErrors = libxml_use_internal_errors(true);

        if ($this->dst === '' || $this->dst === '0') {
            libxml_use_internal_errors($this->previousLibXmlUseErrors);
            throw new TaskException($this, "No destination file is set. Use `->into()` method to set result HTML");
        }

        $this->printTaskInfo("Remove not existing HTML reports...");
        foreach ($this->src as $index => $item) {
            if (!file_exists($item)) {
                unset($this->src[$index]);
                $this->printTaskWarning(
                    "HTML report {$item} did not exist and was removed from merge list"
                );
            }
        }

        // Resetting keys
        $this->src = array_values($this->src);

        $this->printTaskInfo("Merging HTML reports into {$this->dst}");

        //read first source file as main
        $dstHTML = new DOMDocument();
        $dstHTML->loadHTMLFile($this->src[0], LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
        $this->countExecutionTime($dstHTML);
        //main node for all table rows
        $nodeList = (new DOMXPath($dstHTML))->query("//table");
        if (!$nodeList) {
            throw XPathExpressionException::malformedXPath("//table");
        }

        $index = 0;
        $table = $nodeList->item($index);
        if (!$table instanceof DOMNode) {
            throw new KeyNotFoundException('Could not find table item at pos: ' . $index);
        }

        //prepare reference nodes for envs
        $xpathExprRefNodes = "//div[@class='layout']/table/tr[not(@class)]";
        $refnodes = (new DOMXPath($dstHTML))->query($xpathExprRefNodes);
        if (!$refnodes) {
            throw XPathExpressionException::malformedXPath($xpathExprRefNodes);
        }

        for ($k = 1, $kMax = count($this->src); $k < $kMax; ++$k) {
            $src = $this->src[$k];
            if (!file_exists($src) || !is_readable($src)) {
                $this->printTaskWarning('File did not exists or is not readable: ' . $src);
                continue;
            }

            $srcHTML = new DOMDocument();
            $srcHTML->loadHTMLFile($src, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
            $this->countExecutionTime($srcHTML);
            $xpathExprSuiteNodes = "//div[@class='layout']/table/tr";
            $suiteNodes = (new DOMXPath($srcHTML))->query($xpathExprSuiteNodes);
            if (!$suiteNodes) {
                throw XPathExpressionException::malformedXPath($xpathExprSuiteNodes);
            }

            $j = 0;
            foreach ($suiteNodes as $suiteNode) {
                if ($suiteNode->getAttribute('class') == '') {
                    //move to next reference node
                    ++$j;
                    if ($j > $refnodes->length - 1) {
                        break;
                    }

                    continue;
                }

                //insert nodes before current reference node
                $suiteNode = $dstHTML->importNode($suiteNode, true);
                $table->insertBefore($suiteNode, $refnodes->item($j));
            }
        }

        /**
         * The next 6 functions correct our almost finished final report
         */
        $this->countSummary($dstHTML);
        $this->moveSummaryTable($dstHTML, $table);
        $this->updateSummaryTable($dstHTML);
        $this->updateToolbarTable($dstHTML);
        $this->updateButtons($dstHTML);
        $this->updateHeaderLine($dstHTML);

        //save final report
        file_put_contents($this->dst, $dstHTML->saveHTML());

        //return to initial statement
        libxml_use_internal_errors($this->previousLibXmlUseErrors);
    }

    /**
     * This function sums all execution time of each report
     * @param DOMDocument $dstFile
     * @throws XPathExpressionException
     */
    private function countExecutionTime(DOMDocument $dstFile): void
    {
        $xpathHeadline = "//h1[text() = 'Codeception Results ']";
        $nodeList = (new DOMXPath($dstFile))
            ->query($xpathHeadline);
        if (!$nodeList) {
            throw XPathExpressionException::malformedXPath($xpathHeadline);
        }
        $hoursMinutesSeconds = '(([0-1]?\d|2[0-3])(?::([0-5]?\d))?(?::([0-5]?\d))\.\d+)';
        $seconds = '\d+\.\d+s';
        $pregResult = preg_match(
            "#^Codeception Results .* \((?<timesum>$hoursMinutesSeconds|$seconds)\)$#",
            $nodeList[0]->nodeValue,
            $matches
        );

        if (false === $pregResult) {
            throw new RuntimeException('Regexpression is malformed');
        }

        if (0 === $pregResult) {
            return;
        }

        if (str_contains($matches['timesum'], 's')) {
            $matches['timesum'] = str_replace('s', '', $matches['timesum']);
        }
        if (!$this->maxTime) {
            if (str_contains($matches['timesum'], ':')) {
                $this->executionTimeSum = $this->sumTime(strval($this->executionTimeSum), (string)$matches['timesum']);
            } else {
                $this->executionTimeSum += (float)$matches['timesum'];
            }
        } else {
            $this->executionTime[] = (string)$matches['timesum'];
        }
    }

    /**
     * @param DOMDocument $dstFile
     * @throws XPathExpressionException
     */
    private function updateHeaderLine(DOMDocument $dstFile): void
    {
        $xpathHeadline = "//h1[text() = 'Codeception Results ']";
        $nodeList = (new DOMXPath($dstFile))
            ->query($xpathHeadline);
        if (!$nodeList) {
            throw XPathExpressionException::malformedXPath($xpathHeadline);
        }

        /** @var DOMNode $executionTimeNode */
        $executionTimeNode = $nodeList[0]->childNodes[1]->childNodes[1];
        /** @var DOMAttr $statusAttr */
        $statusNode = $nodeList[0]->childNodes[1]->childNodes[0];
        $statusAttr = $statusNode->attributes[0];
        if (0 !== $this->countFailed) {
            $statusNode->nodeValue = 'FAILED';
            $statusAttr->value = 'color: red';
        }
        if (!$this->maxTime) {
            $executionTime = (string)$this->executionTimeSum;
        } else {
            usort($this->executionTime, function ($a, $b) {
                return strcmp($a, $b);
            });
            $executionTime = max($this->executionTime);
        }
        $executionTimeNode->nodeValue = sprintf(
            (preg_match('#([0-1]?\d|2[0-3])(?::([0-5]?\d))?(?::([0-5]?\d))\.\d+#', $executionTime))
                ? ' (%s)'
                : ' (%ss)',
            $executionTime
        );
    }

    /**
     * This function counts all types of tests' scenarios and writes in class members
     *
     * @param DOMDocument $dstFile - destination file
     * @throws XPathExpressionException
     */
    private function countSummary(DOMDocument $dstFile): void
    {
        $xpathExprTests = "//table/tr[contains(@class,'scenarioRow')]";
        /** @var DOMNodeList $tests */
        $tests = (new DOMXPath($dstFile))->query($xpathExprTests);
        if (!$tests) {
            throw XPathExpressionException::malformedXPath($xpathExprTests);
        }

        foreach ($tests as $test) {
            $class = str_replace('scenarioRow ', '', $test->getAttribute('class'));
            switch ($class) {
                case 'scenarioSuccess':
                    $this->countSuccess += 0.5;
                    break;
                case 'scenarioFailed':
                    $this->countFailed += 0.5;
                    break;
                case 'scenarioSkipped':
                    $this->countSkipped += 0.5;
                    break;
                case 'scenarioIncomplete':
                    $this->countIncomplete += 0.5;
                    break;
            }
        }
    }

    /**
     * This function updates values in Summary block for each type of scenarios
     *
     * @param DOMDocument $dstFile - destination file
     */
    private function updateSummaryTable(DOMDocument $dstFile): void
    {
        $dstFile = new DOMXPath($dstFile);
        $pathFor = fn($type): string => sprintf("//div[@id='stepContainerSummary']//td[@class='%s']", $type);
        $dstFile->query($pathFor('scenarioSuccessValue'))->item(0)->nodeValue = (string)$this->countSuccess;
        $dstFile->query($pathFor('scenarioFailedValue'))->item(0)->nodeValue = (string)$this->countFailed;
        $dstFile->query($pathFor('scenarioSkippedValue'))->item(0)->nodeValue = (string)$this->countSkipped;
        $dstFile->query($pathFor('scenarioIncompleteValue'))->item(0)->nodeValue = (string)$this->countIncomplete;
    }

    /**
     * This function moves Summary block in the bottom of result report
     *
     * @param $dstFile DOMDocument - destination file
     * @param $node DOMNode - parent node of Summary table
     */
    private function moveSummaryTable(DOMDocument $dstFile, DOMNode $node): void
    {
        $summaryTable = (new DOMXPath($dstFile))->query("//div[@id='stepContainerSummary']")
            ->item(0)->parentNode->parentNode;
        $node->appendChild($dstFile->importNode($summaryTable, true));
    }

    /**
     * This function updates values in Toolbar block for each type of scenarios
     * (blue block on the left side of the report)
     *
     * @param DOMDocument $dstFile  - destination file
     */
    private function updateToolbarTable(DOMDocument $dstFile): void
    {
        $dstFile = new DOMXPath($dstFile);
        $pathFor = static fn(string $type): string => sprintf("//ul[@id='toolbar-filter']//a[@title='%s']", $type);
        $dstFile->query($pathFor('Successful'))->item(0)->nodeValue = '✔ ' . $this->countSuccess;
        $dstFile->query($pathFor('Failed'))->item(0)->nodeValue = '✗ ' . $this->countFailed;
        $dstFile->query($pathFor('Skipped'))->item(0)->nodeValue = 'S ' . $this->countSkipped;
        $dstFile->query($pathFor('Incomplete'))->item(0)->nodeValue = 'I ' . $this->countIncomplete;
    }

    /**
     * This function updates "+" and "-" button for viewing test steps in final report
     *
     * @param $dstFile DOMDocument - destination file
     * @throws XPathExpressionException
     */
    private function updateButtons(DOMDocument $dstFile): void
    {
        $xpathExprNodes = "//div[@class='layout']/table/tr[contains(@class, 'scenarioRow')]";
        $nodes = (new DOMXPath($dstFile))->query($xpathExprNodes);
        if (!$nodes) {
            throw XPathExpressionException::malformedXPath($xpathExprNodes);
        }

        for ($i = 2; $i < $nodes->length; $i += 2) {
            $n = $i / 2 + 1;
            $p = $nodes->item($i)->childNodes->item(1)->childNodes->item(1);
            $table = $nodes->item($i + 1)->childNodes->item(1)->childNodes->item(1);
            $p->setAttribute('onclick', "showHide('{$n}', this)");
            $table->setAttribute('id', "stepContainer" . $n);
        }
    }

    private function sumTime(string $time1, string $time2): string
    {
        $times = [$time1,  $time2];
        $seconds = 0;
        $milliseconds = 0;
        $isHour = false;
        foreach ($times as $time) {
            if ($time !== '0') {
                $output = explode(':', $time);
                if (count($output) > 2) {
                    $isHour = true;
                    [$hour, $minute, $second] = $output;
                    $seconds += $hour * 3600;
                } else {
                    [$minute, $second] = $output;
                }
                $seconds += $minute * 60;
                [$second, $millisecond] = explode('.', $second);
                $seconds += $second;
                $milliseconds += $millisecond;
            }
        }
        if ($isHour) {
            $hours = floor($seconds / 3600);
            $seconds -= $hours * 3600;
        }
        $minutes  = floor($seconds / 60);
        $seconds -= $minutes * 60;

        return $isHour
            ? sprintf('%02d:%02d:%02d.%02d', $hours, $minutes, $seconds, $milliseconds)
            : sprintf('%02d:%02d.%02d', $minutes, $seconds, $milliseconds);
    }
}
