<?php

namespace Spatie\Backtrace\Tests;

use DateTime;
use PHPUnit\Framework\TestSuite;
use ReflectionClass;
use Spatie\Backtrace\Arguments\ArgumentReducers;
use Spatie\Backtrace\Backtrace;
use Spatie\Backtrace\Frame;
use Spatie\Backtrace\Tests\TestClasses\FakeArgumentReducer;
use Spatie\Backtrace\Tests\TestClasses\LaravelSerializableClosureCallThrow;
use Spatie\Backtrace\Tests\TestClasses\LaravelSerializableClosureThrow;
use Spatie\Backtrace\Tests\TestClasses\ThrowAndReturnExceptionAction;
use Spatie\Backtrace\Tests\TestClasses\TraceArguments;

class BacktraceTest extends TestCase
{
    /** @test */
    public function it_can_create_a_backtrace()
    {
        $frames = Backtrace::create()->frames();

        $this->assertGreaterThan(10, count($frames));

        /** @var \Spatie\Backtrace\Frame $firstFrame */
        $firstFrame = $frames[0];

        $this->assertEquals(__LINE__ - 7, $firstFrame->lineNumber);
        $this->assertEquals(__FILE__, $firstFrame->file);
        $this->assertEquals(static::class, $firstFrame->class);
        $this->assertEquals(explode('::', __METHOD__)[1], $firstFrame->method);
    }

    /** @test */
    public function it_can_get_add_the_arguments()
    {
        /** @var \Spatie\Backtrace\Frame $firstFrame */
        $firstFrame = Backtrace::create()->frames()[0];

        $this->assertNull($firstFrame->arguments);

        /** @var \Spatie\Backtrace\Frame $firstFrame */
        $firstFrame = Backtrace::create()
            ->withArguments()
            ->frames()[0];

        $this->assertIsArray($firstFrame->arguments);
    }

    /** @test */
    public function it_can_disable_the_use_of_arguments_with_a_backtrace()
    {
        function createBackTraceWithoutArguments(string $string): array
        {
            return Backtrace::create()
                ->withArguments(false)
                ->frames();
        }

        $frames = createBackTraceWithoutArguments('Hello World');

        $this->assertNull($frames[1]->arguments);
    }

    /** @test */
    public function it_can_disable_the_use_of_arguments_with_a_throwable()
    {
        $exception = TraceArguments::create()->exception(
            'Hello World',
            new DateTime(),
        );

        $this->assertNull(
            Backtrace::createForThrowable($exception)
                ->withArguments(false)
                ->frames()[1]
                ->arguments
        );
    }

    /** @test */
    public function it_can_get_add_the_arguments_reduced()
    {
        function createBackTrace(string $test, bool $withArguments): Frame
        {
            return Backtrace::create()
                ->withArguments($withArguments)
                ->reduceArguments()
                ->frames()[1];
        }

        $frame = createBackTrace('test', false);

        $this->assertNull($frame->arguments);

        $frame = createBackTrace('test', true);

        $this->assertEquals([
            "name" => "test",
            "value" => "test",
            "original_type" => 'string',
            "passed_by_reference" => false,
            "is_variadic" => false,
            "truncated" => false,
        ], $frame->arguments[0]);
    }

    /** @test */
    public function it_can_manually_define_a_reducer_using_an_array()
    {
        function createBackTraceWithReducerFromArray(string $test): Frame
        {
            return Backtrace::create()
                ->withArguments()
                ->reduceArguments([new FakeArgumentReducer()])
                ->frames()[1];
        }

        $frame = createBackTraceWithReducerFromArray('test', true);

        $this->assertEquals('FAKE', $frame->arguments[0]['value']);
    }

    /** @test */
    public function it_can_manually_define_a_reducer_using_an_argument_reducers_object()
    {
        function createBackTraceWithReducerFromObject(string $test): Frame
        {
            return Backtrace::create()
                ->withArguments()
                ->reduceArguments(ArgumentReducers::default([new FakeArgumentReducer()]))
                ->frames()[1];
        }

        $frame = createBackTraceWithReducerFromObject('test', true);

        $this->assertEquals('FAKE', $frame->arguments[0]['value']);
    }

    /** @test */
    public function it_can_get_the_snippet_around_the_frame()
    {
        /** @var \Spatie\Backtrace\Frame $firstFrame */
        $firstFrame = Backtrace::create()->frames()[0];

        $snippet = $firstFrame->getSnippet(5);

        $this->assertStringContainsString('$firstFrame =', $snippet[__LINE__ - 4]);
        $this->assertCount(5, $snippet);
        $this->assertEquals(__LINE__ - 8, array_key_first($snippet));
    }

    /** @test */
    public function it_can_get_the_snippet_properties()
    {
        /** @var \Spatie\Backtrace\Frame $firstFrame */
        $firstFrame = Backtrace::create()->frames()[0];

        $snippet = $firstFrame->getSnippetProperties(5);

        $this->assertStringContainsString('$firstFrame =', $snippet[2]['text']);
        $this->assertEquals(__LINE__ - 5, $snippet[2]['line_number']);
    }

    /** @test */
    public function it_can_limit_the_amount_of_frames()
    {
        $frames = Backtrace::create()->limit(5)->frames();

        $this->assertCount(5, $frames);
    }

    /** @test */
    public function it_can_start_at_a_specific_frame()
    {
        $firstFrame = Backtrace::create()
            ->startingFromFrame(function (Frame $frame) {
                return $frame->class = TestSuite::class;
            })
            ->frames()[0];

        $this->assertEquals(TestSuite::class, $firstFrame->class);
    }

    /** @test */
    public function it_can_start_at_a_specific_frame_and_limit_the_number_of_frames()
    {
        $frames = Backtrace::create()
            ->startingFromFrame(function (Frame $frame) {
                return $frame->class = TestSuite::class;
            })
            ->limit(2)
            ->frames();

        $this->assertEquals(TestSuite::class, $frames[0]->class);
        $this->assertCount(2, $frames);
    }

    /** @test */
    public function it_can_skip_frames()
    {
        /** @var Frame $firstFrame */
        $firstFrame = Backtrace::create()->offset(1)->frames()[0];

        $this->assertEquals('runTest', $firstFrame->method);
    }

    /** @test */
    public function it_can_get_a_backtrace_from_a_throwable()
    {
        $throwable = ThrowAndReturnExceptionAction::getThrowable();

        $frames = Backtrace::createForThrowable($throwable)->frames();

        $this->assertGreaterThan(10, count($frames));

        /** @var Frame $firstFrame */
        $firstFrame = $frames[0];

        $this->assertEquals(13, $firstFrame->lineNumber);
        $this->assertEquals(ThrowAndReturnExceptionAction::class, $firstFrame->class);
        $this->assertEquals('getThrowable', $firstFrame->method);
    }

    /** @test */
    public function it_can_handle_a_laravel_serializable_closure_via_throwable()
    {
        if (version_compare(PHP_VERSION, '8.1', '<')) {
            $this->markTestSkipped('Enums are only supported in PHP 8.1+');
        }

        $throwable = LaravelSerializableClosureThrow::getThrowable();

        $frames = Backtrace::createForThrowable($throwable)->frames();

        $this->assertGreaterThan(10, count($frames));

        /** @var Frame $firstFrame */
        $firstFrame = $frames[0];

        $this->assertEquals(2, $firstFrame->lineNumber);

        if (version_compare(PHP_VERSION, '8.4', '>=')) {
            $this->assertEquals(
                <<<'EOT'
{closure:laravel-serializable-closure://function () {
            throw new \Exception('This is a test exception from a serialized closure');
        }:2}
EOT,
                $firstFrame->method
            );
        } else {
            $this->assertEquals('{closure}', $firstFrame->method);
        }

        $this->assertEquals(LaravelSerializableClosureThrow::class, $firstFrame->class);
        $this->assertTrue($firstFrame->applicationFrame);

        $firstFrameSnippet = $firstFrame->getSnippetAsString(5);

        $this->assertEquals(
            <<<'EOT'
1 laravel-serializable-closure://function () {
2     throw new \Exception('This is a test exception from a serialized closure');
3 }
EOT,
            $firstFrameSnippet
        );
    }

    /** @test */
    public function it_can_handle_a_laravel_serializable_closure_via_call_throwable()
    {
        if (version_compare(PHP_VERSION, '8.1', '<')) {
            $this->markTestSkipped('Enums are only supported in PHP 8.1+');
        }

        $throwable = LaravelSerializableClosureCallThrow::getThrowable();

        $frames = Backtrace::createForThrowable($throwable)->frames();

        /** @var Frame $firstFrame */
        $firstFrame = $frames[0];

        $this->assertEquals(29, $firstFrame->lineNumber);
        $this->assertEquals('throw', $firstFrame->method);
        $this->assertEquals(LaravelSerializableClosureCallThrow::class, $firstFrame->class);
        $this->assertEquals(realpath(__DIR__.'/../tests/TestClasses/LaravelSerializableClosureCallThrow.php'), $firstFrame->file);
        $this->assertTrue($firstFrame->applicationFrame);

        /** @var Frame $secondFrame */
        $secondFrame = $frames[1];

        $this->assertEquals(2, $secondFrame->lineNumber);

        if (version_compare(PHP_VERSION, '8.4', '>=')) {
            $this->assertEquals(
                <<<'EOT'
{closure:laravel-serializable-closure://function () {
            self::throw();
        }:2}
EOT,
                $secondFrame->method
            );
        } else {
            $this->assertEquals('{closure}', $secondFrame->method);
        }

        $this->assertEquals(LaravelSerializableClosureCallThrow::class, $secondFrame->class);
        $this->assertTrue($secondFrame->applicationFrame);

        $secondFrameSnippet = $secondFrame->getSnippetAsString(5);

        $this->assertEquals(
            <<<'EOT'
1 laravel-serializable-closure://function () {
2     self::throw();
3 }
EOT,
            $secondFrameSnippet
        );
    }

    /** @test */
    public function it_can_get_the_index_of_the_first_application_frame()
    {
        $this->assertEquals(0, Backtrace::create()->firstApplicationFrameIndex());
    }

    /** @test */
    public function it_trims_file_path_if_application_path_set()
    {
        $className = (new ReflectionClass(self::class))->getShortName();

        $this->assertEquals(DIRECTORY_SEPARATOR.$className.'.php', Backtrace::create()->applicationPath(__DIR__)->trimFilePaths()->frames()[0]->trimmedFilePath);
    }

    /** @test */
    public function it_does_not_trim_file_path_if_no_application_path_set()
    {
        $this->assertNull(Backtrace::create()->trimFilePaths()->frames()[0]->trimmedFilePath);
    }
}
