<?php declare(strict_types=1);
/*
 * This file is part of PHPUnit.
 *
 * (c) Sebastian Bergmann <sebastian@phpunit.de>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */
namespace PHPUnit\Logging\OpenTestReporting;

use const PHP_VERSION;
use const ZEND_THREAD_SAFE;
use function array_pop;
use function assert;
use function count;
use function error_get_last;
use function str_replace;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Facade;
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\PreparationErrored;
use PHPUnit\Event\Test\PreparationFailed;
use PHPUnit\Event\Test\Prepared as TestStarted;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\Skipped as TestSuiteSkipped;
use PHPUnit\Event\TestSuite\Started as TestSuiteStarted;
use PHPUnit\Event\TestSuite\TestSuiteForTestClass;
use XMLWriter;

/**
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
 *
 * @internal This class is not covered by the backward compatibility promise for PHPUnit
 */
final class OtrXmlLogger
{
    private readonly XMLWriter $writer;

    /**
     * @var non-negative-int
     */
    private int $idSequence = 0;

    /**
     * @var ?positive-int
     */
    private ?int $parentId = null;

    /**
     * @var list<positive-int>
     */
    private array $parentIdStack = [];

    /**
     * @var ?positive-int
     */
    private ?int $testId              = null;
    private ?Throwable $parentErrored = null;
    private ?Throwable $parentFailed  = null;
    private bool $alreadyFinished     = false;
    private bool $includeGitInformation;

    /**
     * @param non-empty-string $uri
     *
     * @throws CannotOpenUriForWritingException
     */
    public function __construct(Facade $facade, string $uri, bool $includeGitInformation)
    {
        $this->writer = new XMLWriter;

        if (@$this->writer->openUri($uri) === false) {
            throw new CannotOpenUriForWritingException(
                str_replace('XMLWriter::openUri(): ', '', error_get_last()['message']),
            );
        }

        $this->writer->setIndent(true);

        $this->registerSubscribers($facade);

        $this->includeGitInformation = $includeGitInformation;
    }

    public function testRunnerStarted(): void
    {
        $infrastructure = new InfrastructureInformationProvider;
        $gitInformation = false;

        if ($this->includeGitInformation) {
            $gitInformation = $infrastructure->gitInformation();
        }

        $this->writer->startDocument();

        $this->writer->startElement('e:events');
        $this->writer->writeAttribute('xmlns', 'https://schemas.opentest4j.org/reporting/core/0.2.0');
        $this->writer->writeAttribute('xmlns:e', 'https://schemas.opentest4j.org/reporting/events/0.2.0');

        if ($gitInformation !== false) {
            $this->writer->writeAttribute('xmlns:git', 'https://schemas.opentest4j.org/reporting/git/0.2.0');
        }

        $this->writer->writeAttribute('xmlns:php', 'https://schema.phpunit.de/otr/php/0.0.1');
        $this->writer->writeAttribute('xmlns:phpunit', 'https://schema.phpunit.de/otr/phpunit/0.0.1');

        $this->writer->startElement('infrastructure');
        $this->writer->writeElement('hostName', $infrastructure->hostName());
        $this->writer->writeElement('userName', $infrastructure->userName());
        $this->writer->writeElement('operatingSystem', $infrastructure->operatingSystem());

        $this->writer->writeElement('php:phpVersion', PHP_VERSION);
        $this->writer->writeElement('php:threadModel', ZEND_THREAD_SAFE ? 'ZTS' : 'NTS');

        if ($gitInformation !== false) {
            $this->writer->startElement('git:repository');
            $this->writer->writeAttribute('originUrl', $gitInformation['originUrl']);
            $this->writer->endElement();

            $this->writer->writeElement('git:branch', $gitInformation['branch']);
            $this->writer->writeElement('git:commit', $gitInformation['commit']);

            $this->writer->startElement('git:status');
            $this->writer->writeAttribute('clean', $gitInformation['clean'] === true ? 'true' : 'false');
            $this->writer->writeCdata($gitInformation['status']);
            $this->writer->endElement();
        }

        $this->writer->endElement();

        $this->writer->flush();
    }

    public function testRunnerFinished(): void
    {
        $this->writer->endDocument();

        $this->writer->flush();
    }

    public function testSuiteStarted(TestSuiteStarted $event): void
    {
        $id = $this->nextId();

        $this->writer->startElement('e:started');
        $this->writer->writeAttribute('id', (string) $id);

        if ($this->parentId !== null) {
            $this->writer->writeAttribute('parentId', (string) $this->parentId);
        }

        $testSuite = $event->testSuite();

        $this->writer->writeAttribute('name', $testSuite->name());
        $this->writer->writeAttribute('time', $this->timestamp());

        if ($testSuite->isForTestClass()) {
            assert($testSuite instanceof TestSuiteForTestClass);

            $this->writer->startElement('sources');

            $this->writer->startElement('fileSource');
            $this->writer->writeAttribute('path', $testSuite->file());
            $this->writer->startElement('filePosition');
            $this->writer->writeAttribute('line', (string) $testSuite->line());
            $this->writer->endElement();
            $this->writer->endElement();

            $this->writer->startElement('phpunit:classSource');
            $this->writer->writeAttribute('className', $testSuite->className());
            $this->writer->endElement();

            $this->writer->endElement();
        }

        $this->writer->endElement();

        $this->writer->flush();

        $this->parentId        = $id;
        $this->parentIdStack[] = $id;
    }

    public function testSuiteSkipped(TestSuiteSkipped $event): void
    {
        $this->writer->startElement('e:finished');
        $this->writer->writeAttribute('id', (string) $this->parentId);
        $this->writer->writeAttribute('time', $this->timestamp());

        $this->writer->startElement('result');
        $this->writer->writeAttribute('status', 'SKIPPED');
        $this->writer->writeElement('reason', $event->message());
        $this->writer->endElement();

        $this->writer->endElement();

        $this->writer->flush();

        $this->reduceTestSuiteLevel();
    }

    public function testSuiteFinished(): void
    {
        $this->writer->startElement('e:finished');
        $this->writer->writeAttribute('id', (string) $this->parentId);
        $this->writer->writeAttribute('time', $this->timestamp());

        if ($this->parentErrored !== null) {
            $this->writer->startElement('result');
            $this->writer->writeAttribute('status', 'ERRORED');
            $this->writer->writeElement('reason', $this->parentErrored->message());
            $this->writeThrowable($this->parentErrored, false);
            $this->writer->endElement();
        } elseif ($this->parentFailed !== null) {
            $this->writer->startElement('result');
            $this->writer->writeAttribute('status', 'FAILED');
            $this->writer->writeElement('reason', $this->parentFailed->message());
            $this->writeThrowable($this->parentFailed, true);
            $this->writer->endElement();
        }

        $this->writer->endElement();

        $this->writer->flush();

        $this->parentErrored = null;
        $this->parentFailed  = null;

        $this->reduceTestSuiteLevel();
    }

    public function testPrepared(PreparationErrored|PreparationFailed|TestStarted $event): void
    {
        $this->testId = $this->nextId();

        $this->writeTestStarted(
            $event->test(),
            $this->testId,
            $this->parentId,
        );
    }

    public function testFinished(): void
    {
        if (!$this->alreadyFinished) {
            $this->writer->startElement('e:finished');
            $this->writer->writeAttribute('id', (string) $this->testId);
            $this->writer->writeAttribute('time', $this->timestamp());
            $this->writer->startElement('result');
            $this->writer->writeAttribute('status', Status::Successful->value);
            $this->writer->endElement();
            $this->writer->endElement();
        }

        $this->alreadyFinished = false;
        $this->testId          = null;
    }

    public function testFailed(Failed $event): void
    {
        $this->writer->startElement('e:finished');
        $this->writer->writeAttribute('id', (string) $this->testId);
        $this->writer->writeAttribute('time', $this->timestamp());
        $this->writer->startElement('result');
        $this->writer->writeAttribute('status', Status::Failed->value);

        $this->writer->writeElement('reason', $event->throwable()->message());
        $this->writeThrowable($event->throwable(), true);

        $this->writer->endElement();
        $this->writer->endElement();

        $this->writer->flush();

        $this->alreadyFinished = true;
    }

    public function testErrored(Errored $event): void
    {
        $this->writer->startElement('e:finished');
        $this->writer->writeAttribute('id', (string) $this->testId);
        $this->writer->writeAttribute('time', $this->timestamp());
        $this->writer->startElement('result');
        $this->writer->writeAttribute('status', Status::Errored->value);

        $this->writer->writeElement('reason', $event->throwable()->message());
        $this->writeThrowable($event->throwable(), false);

        $this->writer->endElement();
        $this->writer->endElement();

        $this->writer->flush();

        $this->alreadyFinished = true;
    }

    public function testSkipped(Skipped $event): void
    {
        if ($this->testId === null) {
            $this->testId = $this->nextId();

            $this->writeTestStarted(
                $event->test(),
                $this->testId,
                $this->parentId,
            );
        }

        $this->writer->startElement('e:finished');
        $this->writer->writeAttribute('id', (string) $this->testId);
        $this->writer->writeAttribute('time', $this->timestamp());
        $this->writer->startElement('result');
        $this->writer->writeAttribute('status', Status::Skipped->value);

        $this->writer->writeElement('reason', $event->message());

        $this->writer->endElement();
        $this->writer->endElement();

        $this->writer->flush();

        $this->alreadyFinished = true;
    }

    public function markTestIncomplete(MarkedIncomplete $event): void
    {
        $this->writer->startElement('e:finished');
        $this->writer->writeAttribute('id', (string) $this->testId);
        $this->writer->writeAttribute('time', $this->timestamp());
        $this->writer->startElement('result');
        $this->writer->writeAttribute('status', Status::Aborted->value);

        $this->writer->writeElement('reason', $event->throwable()->message());
        $this->writeThrowable($event->throwable(), false);

        $this->writer->endElement();
        $this->writer->endElement();

        $this->writer->flush();

        $this->alreadyFinished = true;
    }

    public function parentErrored(AfterLastTestMethodErrored|BeforeFirstTestMethodErrored $event): void
    {
        $this->parentErrored = $event->throwable();
    }

    public function parentFailed(AfterLastTestMethodFailed|BeforeFirstTestMethodFailed $event): void
    {
        $this->parentFailed = $event->throwable();
    }

    private function registerSubscribers(Facade $facade): void
    {
        $facade->registerSubscribers(
            new TestRunnerStartedSubscriber($this),
            new TestSuiteStartedSubscriber($this),
            new TestSuiteSkippedSubscriber($this),
            new BeforeFirstTestMethodErroredSubscriber($this),
            new BeforeFirstTestMethodFailedSubscriber($this),
            new AfterLastTestMethodErroredSubscriber($this),
            new AfterLastTestMethodFailedSubscriber($this),
            new TestPreparationErroredSubscriber($this),
            new TestPreparationFailedSubscriber($this),
            new TestPreparedSubscriber($this),
            new TestAbortedSubscriber($this),
            new TestErroredSubscriber($this),
            new TestFailedSubscriber($this),
            new TestSkippedSubscriber($this),
            new TestFinishedSubscriber($this),
            new TestSuiteFinishedSubscriber($this),
            new TestRunnerFinishedSubscriber($this),
        );
    }

    /**
     * @param positive-int  $id
     * @param ?positive-int $parentId
     */
    private function writeTestStarted(Test $test, int $id, ?int $parentId): void
    {
        $this->writer->startElement('e:started');
        $this->writer->writeAttribute('id', (string) $id);

        if ($parentId !== null) {
            $this->writer->writeAttribute('parentId', (string) $parentId);
        }

        $this->writer->writeAttribute('name', $test->name());
        $this->writer->writeAttribute('time', $this->timestamp());

        $this->writer->startElement('sources');

        $this->writer->startElement('fileSource');
        $this->writer->writeAttribute('path', $test->file());

        if ($test->isTestMethod()) {
            assert($test instanceof TestMethod);

            $this->writer->startElement('filePosition');
            $this->writer->writeAttribute('line', (string) $test->line());
            $this->writer->endElement();
        }

        $this->writer->endElement();

        if ($test->isTestMethod()) {
            assert($test instanceof TestMethod);

            $this->writer->startElement('phpunit:methodSource');
            $this->writer->writeAttribute('className', $test->className());
            $this->writer->writeAttribute('methodName', $test->methodName());
            $this->writer->endElement();
        }

        $this->writer->endElement();

        $this->writer->endElement();

        $this->writer->flush();
    }

    private function writeThrowable(Throwable $throwable, bool $assertionError): void
    {
        $this->writer->startElement('phpunit:throwable');
        $this->writer->writeAttribute('type', $throwable->className());
        $this->writer->writeAttribute('assertionError', $assertionError ? 'true' : 'false');
        $this->writer->writeCdata($throwable->asString());
        $this->writer->endElement();
    }

    /**
     * @return non-empty-string
     */
    private function timestamp(): string
    {
        return (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.u\Z');
    }

    /**
     * @return positive-int
     */
    private function nextId(): int
    {
        return ++$this->idSequence;
    }

    private function reduceTestSuiteLevel(): void
    {
        array_pop($this->parentIdStack);

        if ($this->parentIdStack !== []) {
            $this->parentId = $this->parentIdStack[count($this->parentIdStack) - 1];

            return;
        }

        $this->parentId = null;
    }
}
