<?php

declare(strict_types=1);

namespace Pest\Logging;

use NunoMaduro\Collision\Adapters\Phpunit\State;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Support\StateGenerator;
use Pest\Support\Str;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
use PHPUnit\Framework\Exception as FrameworkException;
use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult;

/**
 * @internal
 */
final readonly class Converter
{
    /**
     * The prefix for the test suite name.
     */
    private const string PREFIX = 'P\\';

    /**
     *  The state generator.
     */
    private StateGenerator $stateGenerator;

    /**
     * Creates a new instance of the Converter.
     */
    public function __construct(
        private string $rootPath,
    ) {
        $this->stateGenerator = new StateGenerator;
    }

    /**
     * Gets the test case method name.
     */
    public function getTestCaseMethodName(Test $test): string
    {
        if (! $test instanceof TestMethod) {
            throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
        }

        return $test->testDox()->prettifiedMethodName();
    }

    /**
     * Gets the test case location.
     */
    public function getTestCaseLocation(Test $test): string
    {
        if (! $test instanceof TestMethod) {
            throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
        }

        $path = $test->testDox()->prettifiedClassName();
        $relativePath = $this->toRelativePath($path);

        // TODO: Get the description without the dataset.
        $description = $test->testDox()->prettifiedMethodName();

        return "$relativePath::$description";
    }

    /**
     * Gets the exception message.
     */
    public function getExceptionMessage(Throwable $throwable): string
    {
        if (is_a($throwable->className(), FrameworkException::class, true)) {
            return $throwable->message();
        }

        $buffer = $throwable->className();
        $throwableMessage = $throwable->message();

        if ($throwableMessage !== '') {
            $buffer .= ": $throwableMessage";
        }

        return $buffer;
    }

    /**
     * Gets the exception details.
     */
    public function getExceptionDetails(Throwable $throwable): string
    {
        $buffer = $this->getStackTrace($throwable);

        while ($throwable->hasPrevious()) {
            $throwable = $throwable->previous();

            $buffer .= sprintf(
                "\nCaused by\n%s\n%s",
                $throwable->description(),
                $this->getStackTrace($throwable)
            );
        }

        return $buffer;
    }

    /**
     * Gets the stack trace.
     */
    public function getStackTrace(Throwable $throwable): string
    {
        $stackTrace = $throwable->stackTrace();

        // Split stacktrace per frame.
        $frames = explode("\n", $stackTrace);

        // Remove empty lines
        $frames = array_filter($frames);

        // clean the paths of each frame.
        $frames = array_map(
            $this->toRelativePath(...),
            $frames
        );

        // Format stacktrace as `at <path>`
        $frames = array_map(
            fn (string $frame): string => "at $frame",
            $frames
        );

        return implode("\n", $frames);
    }

    /**
     * Gets the test suite name.
     */
    public function getTestSuiteName(TestSuite $testSuite): string
    {
        if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
            $firstTest = $this->getFirstTest($testSuite);
            if ($firstTest instanceof TestMethod) {
                return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
            }
        }

        $name = $testSuite->name();

        if (! str_starts_with($name, self::PREFIX)) {
            return $name;
        }

        return Str::after($name, self::PREFIX);
    }

    /**
     * Gets the trimmed test class name.
     */
    public function getTrimmedTestClassName(TestMethod $test): string
    {
        return Str::after($test->className(), self::PREFIX);
    }

    /**
     * Gets the test suite location.
     */
    public function getTestSuiteLocation(TestSuite $testSuite): ?string
    {
        $firstTest = $this->getFirstTest($testSuite);
        if (! $firstTest instanceof TestMethod) {
            return null;
        }
        $path = $firstTest->testDox()->prettifiedClassName();
        $classRelativePath = $this->toRelativePath($path);

        if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
            $methodName = $this->getTestMethodNameWithoutDatasetSuffix($firstTest);

            return "$classRelativePath::$methodName";
        }

        return $classRelativePath;
    }

    /**
     * Gets the prettified test method name without dataset-related suffix.
     */
    private function getTestMethodNameWithoutDatasetSuffix(TestMethod $testMethod): string
    {
        return Str::beforeLast($testMethod->testDox()->prettifiedMethodName(), ' with data set ');
    }

    /**
     * Gets the first test from the test suite.
     */
    private function getFirstTest(TestSuite $testSuite): ?TestMethod
    {
        $tests = $testSuite->tests()->asArray();

        // TODO: figure out how to get the file path without a test being there.
        if ($tests === []) {
            return null;
        }

        $firstTest = $tests[0];
        if (! $firstTest instanceof TestMethod) {
            throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
        }

        return $firstTest;
    }

    /**
     * Gets the test suite size.
     */
    public function getTestSuiteSize(TestSuite $testSuite): int
    {
        return $testSuite->count();
    }

    /**
     * Transforms the given path in relative path.
     */
    private function toRelativePath(string $path): string
    {
        // Remove cwd from the path.
        return str_replace("$this->rootPath".DIRECTORY_SEPARATOR, '', $path);
    }

    /**
     * Get the test result.
     */
    public function getStateFromResult(PhpUnitTestResult $result): State
    {
        $events = [
            ...$result->testErroredEvents(),
            ...$result->testFailedEvents(),
            ...$result->testSkippedEvents(),
            ...array_merge(...array_values($result->testConsideredRiskyEvents())),
            ...$result->testMarkedIncompleteEvents(),
        ];

        $numberOfNotPassedTests = count(
            array_unique(
                array_map(
                    function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
                        if ($event instanceof BeforeFirstTestMethodErrored
                            || $event instanceof AfterLastTestMethodErrored
                            || $event instanceof BeforeFirstTestMethodFailed
                            || $event instanceof AfterLastTestMethodFailed) {
                            return $event->testClassName();
                        }

                        return $this->getTestCaseLocation($event->test());
                    },
                    $events
                )
            )
        );

        $numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;

        return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
    }
}
