<?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_key_last;
use function array_pop;
use function assert;
use function error_get_last;
use function sprintf;
use function str_replace;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Event\Code\ComparisonFailure;
use PHPUnit\Event\Code\IssueTrigger\IssueTrigger;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Facade;
use PHPUnit\Event\Telemetry\CpuTime;
use PHPUnit\Event\Telemetry\Duration;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\Telemetry\MemoryUsage;
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\AttemptErrored;
use PHPUnit\Event\Test\AttemptFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\DeprecationTriggered;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErrorTriggered;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\Finished as TestLifecycleFinished;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\NoticeTriggered;
use PHPUnit\Event\Test\PhpDeprecationTriggered;
use PHPUnit\Event\Test\PhpNoticeTriggered;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered;
use PHPUnit\Event\Test\PhpunitWarningTriggered;
use PHPUnit\Event\Test\PhpWarningTriggered;
use PHPUnit\Event\Test\PreparationErrored;
use PHPUnit\Event\Test\PreparationFailed;
use PHPUnit\Event\Test\Prepared as TestStarted;
use PHPUnit\Event\Test\PrintedUnexpectedOutput;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\WarningTriggered;
use PHPUnit\Event\TestSuite\Finished as TestSuiteFinishedEvent;
use PHPUnit\Event\TestSuite\Skipped as TestSuiteSkipped;
use PHPUnit\Event\TestSuite\Started as TestSuiteStarted;
use PHPUnit\Event\TestSuite\TestSuiteForTestClass;
use PHPUnit\Metadata\Group;
use SebastianBergmann\Environment\Host;
use SebastianBergmann\GitState\Builder as GitStateBuilder;
use SebastianBergmann\GitState\State as GitState;
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 $testWasPrepared                        = false;
    private bool $alreadyFinished                        = false;
    private ?Status $pendingStatus                       = null;
    private ?string $pendingReason                       = null;
    private ?Throwable $pendingThrowable                 = null;
    private ?bool $pendingAssertionError                 = null;
    private ?ComparisonFailure $pendingComparisonFailure = null;
    private ?HRTime $testStartTime                       = null;
    private ?CpuTime $testStartUserCpuTime               = null;
    private ?CpuTime $testStartSystemCpuTime             = null;
    private ?CpuTime $testStartTotalCpuTime              = null;

    /**
     * Accumulated wall-clock time of the failed attempts that preceded the
     * final attempt of a retried test. This is added to the time of the final
     * attempt so that the reported duration covers all attempts.
     */
    private ?Duration $retriedAttemptsDuration = null;

    /**
     * @var list<HRTime>
     */
    private array $suiteStartTimeStack = [];

    /**
     * @var list<CpuTime>
     */
    private array $suiteStartUserCpuTimeStack = [];

    /**
     * @var list<CpuTime>
     */
    private array $suiteStartSystemCpuTimeStack = [];

    /**
     * @var list<CpuTime>
     */
    private array $suiteStartTotalCpuTimeStack = [];
    private bool $includeGitInformation;
    private ?int $randomOrderSeed;

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

        if (@$this->writer->openUri($uri) === false) {
            $error = error_get_last();

            assert($error !== null);

            throw new CannotOpenUriForWritingException(
                str_replace('XMLWriter::openUri(): ', '', $error['message']),
            );
        }

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

        $this->registerSubscribers($facade);

        $this->includeGitInformation = $includeGitInformation;
        $this->randomOrderSeed       = $randomOrderSeed;
    }

    public function testRunnerStarted(): void
    {
        $host           = new Host;
        $gitInformation = $this->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.1.0');
        $this->writer->writeAttribute('xmlns:phpunit', 'https://schema.phpunit.de/otr/phpunit/0.2.0');

        $this->writer->startElement('infrastructure');
        $this->writer->writeElement('hostName', $host->name());
        $this->writer->writeElement('userName', $host->user());
        $this->writer->writeElement('operatingSystem', $host->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->isClean() ? 'true' : 'false');
            $this->writer->writeCdata($gitInformation->status());
            $this->writer->endElement();
        }

        if ($this->randomOrderSeed !== null) {
            $this->writer->writeElement('phpunit:randomOrderSeed', (string) $this->randomOrderSeed);
        }

        $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('metadata');
            $this->writer->startElement('phpunit:testDox');
            $this->writer->writeAttribute(
                'prettifiedClassName',
                $testSuite->prettifiedName(),
            );
            $this->writer->endElement();
            $this->writer->endElement();

            $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();

        $info = $event->telemetryInfo();

        $this->parentId                       = $id;
        $this->parentIdStack[]                = $id;
        $this->suiteStartTimeStack[]          = $info->time();
        $this->suiteStartUserCpuTimeStack[]   = $info->userCpuTime();
        $this->suiteStartSystemCpuTimeStack[] = $info->systemCpuTime();
        $this->suiteStartTotalCpuTimeStack[]  = $info->totalCpuTime();
    }

    public function testSuiteSkipped(TestSuiteSkipped $event): void
    {
        $info = $event->telemetryInfo();

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

        $this->writeSuiteResourceUsage(
            $info->time(),
            $info->memoryUsage(),
            $info->peakMemoryUsage(),
            $info->userCpuTime(),
            $info->systemCpuTime(),
            $info->totalCpuTime(),
        );

        $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(TestSuiteFinishedEvent $event): void
    {
        $info = $event->telemetryInfo();

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

        $this->writeSuiteResourceUsage(
            $info->time(),
            $info->memoryUsage(),
            $info->peakMemoryUsage(),
            $info->userCpuTime(),
            $info->systemCpuTime(),
            $info->totalCpuTime(),
        );

        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
    {
        $info = $event->telemetryInfo();

        $this->testId                 = $this->nextId();
        $this->testStartTime          = $info->time();
        $this->testStartUserCpuTime   = $info->userCpuTime();
        $this->testStartSystemCpuTime = $info->systemCpuTime();
        $this->testStartTotalCpuTime  = $info->totalCpuTime();

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

        if ($event instanceof TestStarted) {
            $this->testWasPrepared = true;
        }
    }

    public function testAttemptFailed(AttemptFailed $event): void
    {
        $this->rememberRetriedAttempt($event->duration());
    }

    public function testAttemptErrored(AttemptErrored $event): void
    {
        $this->rememberRetriedAttempt($event->duration());
    }

    public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $event): void
    {
        $this->writer->startElement('e:reported');
        $this->writer->writeAttribute('id', (string) $this->testId);
        $this->writer->writeAttribute('time', $this->timestamp());

        $this->writer->startElement('attachments');
        $this->writer->startElement('output');
        $this->writer->writeAttribute('source', 'stdout');
        $this->writer->writeAttribute('time', $this->timestamp());
        $this->writer->writeCdata($event->output());
        $this->writer->endElement();
        $this->writer->endElement();

        $this->writer->endElement();

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

    public function testDeprecationTriggered(DeprecationTriggered $event): void
    {
        $trigger = $event->trigger();

        $this->writeIssue(
            'deprecation',
            $event->message(),
            $event->file(),
            $event->line(),
            $event->wasSuppressed(),
            $event->ignoredByBaseline(),
            $event->ignoredByTest(),
            $this->triggerAsString($trigger),
            $trigger->callerAsString(),
            $trigger->calleeAsString(),
        );
    }

    public function testPhpDeprecationTriggered(PhpDeprecationTriggered $event): void
    {
        $trigger = $event->trigger();

        $this->writeIssue(
            'php-deprecation',
            $event->message(),
            $event->file(),
            $event->line(),
            $event->wasSuppressed(),
            $event->ignoredByBaseline(),
            $event->ignoredByTest(),
            $this->triggerAsString($trigger),
            $trigger->callerAsString(),
            $trigger->calleeAsString(),
        );
    }

    public function testPhpunitDeprecationTriggered(PhpunitDeprecationTriggered $event): void
    {
        $this->writeIssue('phpunit-deprecation', $event->message());
    }

    public function testErrorTriggered(ErrorTriggered $event): void
    {
        $this->writeIssue(
            'error',
            $event->message(),
            $event->file(),
            $event->line(),
            $event->wasSuppressed(),
        );
    }

    public function testPhpunitErrorTriggered(PhpunitErrorTriggered $event): void
    {
        $this->writeIssue('phpunit-error', $event->message());
    }

    public function testNoticeTriggered(NoticeTriggered $event): void
    {
        $this->writeIssue(
            'notice',
            $event->message(),
            $event->file(),
            $event->line(),
            $event->wasSuppressed(),
            $event->ignoredByBaseline(),
        );
    }

    public function testPhpNoticeTriggered(PhpNoticeTriggered $event): void
    {
        $this->writeIssue(
            'php-notice',
            $event->message(),
            $event->file(),
            $event->line(),
            $event->wasSuppressed(),
            $event->ignoredByBaseline(),
        );
    }

    public function testPhpunitNoticeTriggered(PhpunitNoticeTriggered $event): void
    {
        $this->writeIssue('phpunit-notice', $event->message());
    }

    public function testWarningTriggered(WarningTriggered $event): void
    {
        $this->writeIssue(
            'warning',
            $event->message(),
            $event->file(),
            $event->line(),
            $event->wasSuppressed(),
            $event->ignoredByBaseline(),
        );
    }

    public function testPhpWarningTriggered(PhpWarningTriggered $event): void
    {
        $this->writeIssue(
            'php-warning',
            $event->message(),
            $event->file(),
            $event->line(),
            $event->wasSuppressed(),
            $event->ignoredByBaseline(),
        );
    }

    public function testPhpunitWarningTriggered(PhpunitWarningTriggered $event): void
    {
        $this->writeIssue('phpunit-warning', $event->message(), null, null, null, null, $event->ignoredByTest());
    }

    public function testConsideredRisky(ConsideredRisky $event): void
    {
        $this->writeIssue('risky', $event->message());
    }

    public function testFinished(TestLifecycleFinished $event): void
    {
        if (!$this->alreadyFinished) {
            $info = $event->telemetryInfo();

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

            $this->writeTestAttachments(
                $info->time(),
                $info->memoryUsage(),
                $info->peakMemoryUsage(),
                $info->userCpuTime(),
                $info->systemCpuTime(),
                $info->totalCpuTime(),
                $event->numberOfAssertionsPerformed(),
            );

            $status = Status::Successful;

            if ($this->pendingStatus !== null) {
                $status = $this->pendingStatus;
            }

            $this->writer->startElement('result');
            $this->writer->writeAttribute('status', $status->value);

            if ($this->pendingReason !== null) {
                $this->writer->writeElement('reason', $this->pendingReason);
            }

            if ($this->pendingThrowable !== null) {
                assert($this->pendingAssertionError !== null);

                $this->writeThrowable($this->pendingThrowable, $this->pendingAssertionError);
            }

            if ($this->pendingComparisonFailure !== null) {
                $this->writeComparisonFailure($this->pendingComparisonFailure);
            }

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

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

        $this->alreadyFinished          = false;
        $this->testWasPrepared          = false;
        $this->testId                   = null;
        $this->retriedAttemptsDuration  = null;
        $this->testStartTime            = null;
        $this->testStartUserCpuTime     = null;
        $this->testStartSystemCpuTime   = null;
        $this->testStartTotalCpuTime    = null;
        $this->pendingStatus            = null;
        $this->pendingReason            = null;
        $this->pendingThrowable         = null;
        $this->pendingAssertionError    = null;
        $this->pendingComparisonFailure = null;
    }

    public function testFailed(Failed $event): void
    {
        if ($this->testWasPrepared) {
            $this->pendingStatus         = Status::Failed;
            $this->pendingReason         = $event->throwable()->message();
            $this->pendingThrowable      = $event->throwable();
            $this->pendingAssertionError = true;

            if ($event->hasComparisonFailure()) {
                $this->pendingComparisonFailure = $event->comparisonFailure();
            }

            return;
        }

        $info = $event->telemetryInfo();

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

        $this->writeTestAttachments(
            $info->time(),
            $info->memoryUsage(),
            $info->peakMemoryUsage(),
            $info->userCpuTime(),
            $info->systemCpuTime(),
            $info->totalCpuTime(),
            null,
        );

        $this->writer->startElement('result');
        $this->writer->writeAttribute('status', Status::Failed->value);

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

        if ($event->hasComparisonFailure()) {
            $this->writeComparisonFailure($event->comparisonFailure());
        }

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

        $this->writer->flush();

        $this->alreadyFinished = true;
    }

    public function testErrored(Errored $event): void
    {
        if ($this->testWasPrepared) {
            $this->pendingStatus         = Status::Errored;
            $this->pendingReason         = $event->throwable()->message();
            $this->pendingThrowable      = $event->throwable();
            $this->pendingAssertionError = false;

            return;
        }

        $info = $event->telemetryInfo();

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

        $this->writeTestAttachments(
            $info->time(),
            $info->memoryUsage(),
            $info->peakMemoryUsage(),
            $info->userCpuTime(),
            $info->systemCpuTime(),
            $info->totalCpuTime(),
            null,
        );

        $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->testId = null;

            return;
        }

        $this->pendingStatus = Status::Skipped;
        $this->pendingReason = $event->message();
    }

    public function markTestIncomplete(MarkedIncomplete $event): void
    {
        $this->pendingStatus         = Status::Aborted;
        $this->pendingReason         = $event->throwable()->message();
        $this->pendingThrowable      = $event->throwable();
        $this->pendingAssertionError = false;
    }

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

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

    private function writeIssue(string $type, string $message, ?string $file = null, ?int $line = null, ?bool $suppressed = null, ?bool $ignoredByBaseline = null, ?bool $ignoredByTest = null, ?string $trigger = null, ?string $caller = null, ?string $callee = null): void
    {
        $this->writer->startElement('e:reported');
        $this->writer->writeAttribute('id', (string) $this->testId);
        $this->writer->writeAttribute('time', $this->timestamp());

        $this->writer->startElement('attachments');
        $this->writer->startElement('phpunit:issue');
        $this->writer->writeAttribute('type', $type);
        $this->writer->writeAttribute('message', $message);

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

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

        if ($suppressed !== null) {
            $this->writer->writeAttribute('suppressed', $suppressed ? 'true' : 'false');
        }

        if ($ignoredByBaseline !== null) {
            $this->writer->writeAttribute('ignoredByBaseline', $ignoredByBaseline ? 'true' : 'false');
        }

        if ($ignoredByTest !== null) {
            $this->writer->writeAttribute('ignoredByTest', $ignoredByTest ? 'true' : 'false');
        }

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

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

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

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

    private function triggerAsString(IssueTrigger $trigger): string
    {
        if ($trigger->isSelf()) {
            return 'self';
        }

        if ($trigger->isDirect()) {
            return 'direct';
        }

        if ($trigger->isIndirect()) {
            return 'indirect';
        }

        return 'unknown';
    }

    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 TestAttemptFailedSubscriber($this),
            new TestAttemptErroredSubscriber($this),
            new TestPrintedUnexpectedOutputSubscriber($this),
            new TestDeprecationTriggeredSubscriber($this),
            new TestPhpDeprecationTriggeredSubscriber($this),
            new TestPhpunitDeprecationTriggeredSubscriber($this),
            new TestErrorTriggeredSubscriber($this),
            new TestPhpunitErrorTriggeredSubscriber($this),
            new TestNoticeTriggeredSubscriber($this),
            new TestPhpNoticeTriggeredSubscriber($this),
            new TestPhpunitNoticeTriggeredSubscriber($this),
            new TestWarningTriggeredSubscriber($this),
            new TestPhpWarningTriggeredSubscriber($this),
            new TestPhpunitWarningTriggeredSubscriber($this),
            new TestConsideredRiskySubscriber($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());

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

            $tags = [];

            foreach ($test->metadata()->isGroup() as $group) {
                assert($group instanceof Group);

                $tags[] = $group->groupName();
            }

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

            if ($tags !== []) {
                $this->writer->startElement('tags');

                foreach ($tags as $tag) {
                    $this->writer->writeElement('tag', $tag);
                }

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

            $this->writer->startElement('phpunit:testDox');

            $this->writer->writeAttribute(
                'prettifiedMethodName',
                $test->testDox()->prettifiedMethodName(),
            );

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

        $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();
    }

    private function writeComparisonFailure(ComparisonFailure $comparisonFailure): void
    {
        $this->writer->startElement('phpunit:comparisonFailure');

        $this->writer->startElement('phpunit:expected');
        $this->writer->writeCdata($comparisonFailure->expected());
        $this->writer->endElement();

        $this->writer->startElement('phpunit:actual');
        $this->writer->writeCdata($comparisonFailure->actual());
        $this->writer->endElement();

        $this->writer->startElement('phpunit:diff');
        $this->writer->writeCdata($comparisonFailure->diff());
        $this->writer->endElement();

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

    private function rememberRetriedAttempt(Duration $duration): void
    {
        if ($this->retriedAttemptsDuration === null) {
            $this->retriedAttemptsDuration = $duration;

            return;
        }

        $this->retriedAttemptsDuration = $this->retriedAttemptsDuration->add($duration);
    }

    private function writeTestAttachments(HRTime $endTime, MemoryUsage $memoryUsage, MemoryUsage $peakMemoryUsage, CpuTime $userCpuTime, CpuTime $systemCpuTime, CpuTime $totalCpuTime, ?int $numberOfAssertionsPerformed): void
    {
        assert($this->testStartTime !== null);
        assert($this->testStartUserCpuTime !== null);
        assert($this->testStartSystemCpuTime !== null);
        assert($this->testStartTotalCpuTime !== null);

        $duration          = $endTime->duration($this->testStartTime);
        $userCpuTimeUsed   = $userCpuTime->diff($this->testStartUserCpuTime);
        $systemCpuTimeUsed = $systemCpuTime->diff($this->testStartSystemCpuTime);
        $totalCpuTimeUsed  = $totalCpuTime->diff($this->testStartTotalCpuTime);

        if ($this->retriedAttemptsDuration !== null) {
            $duration = $duration->add($this->retriedAttemptsDuration);
        }

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

        $this->writeResourceUsageElement($duration, $memoryUsage, $peakMemoryUsage, $userCpuTimeUsed, $systemCpuTimeUsed, $totalCpuTimeUsed);

        if ($numberOfAssertionsPerformed !== null) {
            $this->writer->startElement('phpunit:assertions');
            $this->writer->writeAttribute('count', (string) $numberOfAssertionsPerformed);
            $this->writer->endElement();
        }

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

    private function writeSuiteResourceUsage(HRTime $endTime, MemoryUsage $memoryUsage, MemoryUsage $peakMemoryUsage, CpuTime $userCpuTime, CpuTime $systemCpuTime, CpuTime $totalCpuTime): void
    {
        $startTime          = array_pop($this->suiteStartTimeStack);
        $startUserCpuTime   = array_pop($this->suiteStartUserCpuTimeStack);
        $startSystemCpuTime = array_pop($this->suiteStartSystemCpuTimeStack);
        $startTotalCpuTime  = array_pop($this->suiteStartTotalCpuTimeStack);

        assert($startTime !== null);
        assert($startUserCpuTime !== null);
        assert($startSystemCpuTime !== null);
        assert($startTotalCpuTime !== null);

        $duration          = $endTime->duration($startTime);
        $userCpuTimeUsed   = $userCpuTime->diff($startUserCpuTime);
        $systemCpuTimeUsed = $systemCpuTime->diff($startSystemCpuTime);
        $totalCpuTimeUsed  = $totalCpuTime->diff($startTotalCpuTime);

        $this->writer->startElement('attachments');
        $this->writeResourceUsageElement($duration, $memoryUsage, $peakMemoryUsage, $userCpuTimeUsed, $systemCpuTimeUsed, $totalCpuTimeUsed);
        $this->writer->endElement();
    }

    private function writeResourceUsageElement(Duration $duration, MemoryUsage $memoryUsage, MemoryUsage $peakMemoryUsage, CpuTime $userCpuTime, CpuTime $systemCpuTime, CpuTime $totalCpuTime): void
    {
        $this->writer->startElement('phpunit:resourceUsage');
        $this->writer->writeAttribute('time', sprintf('%d.%09d', $duration->seconds(), $duration->nanoseconds()));
        $this->writer->writeAttribute('memoryUsage', (string) $memoryUsage->bytes());
        $this->writer->writeAttribute('peakMemoryUsage', (string) $peakMemoryUsage->bytes());
        $this->writer->writeAttribute('userCpuTime', sprintf('%d.%09d', $userCpuTime->seconds(), $userCpuTime->nanoseconds()));
        $this->writer->writeAttribute('systemCpuTime', sprintf('%d.%09d', $systemCpuTime->seconds(), $systemCpuTime->nanoseconds()));
        $this->writer->writeAttribute('cpuTime', sprintf('%d.%09d', $totalCpuTime->seconds(), $totalCpuTime->nanoseconds()));
        $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);

        $lastKey = array_key_last($this->parentIdStack);

        if ($lastKey !== null) {
            $this->parentId = $this->parentIdStack[$lastKey];

            return;
        }

        $this->parentId = null;
    }

    private function gitInformation(): false|GitState
    {
        if (!$this->includeGitInformation) {
            return false;
        }

        return (new GitStateBuilder)->build();
    }
}
