<?php

namespace Spatie\Backtrace;

use Closure;
use Laravel\SerializableClosure\Support\ClosureStream;
use Spatie\Backtrace\Arguments\ArgumentReducers;
use Spatie\Backtrace\Arguments\ReduceArgumentsAction;
use Spatie\Backtrace\Arguments\Reducers\ArgumentReducer;
use Throwable;

class Backtrace
{
    /** @var bool */
    protected $withArguments = false;

    /** @var bool */
    protected $reduceArguments = false;

    /** @var array<class-string<ArgumentReducer>|ArgumentReducer>|ArgumentReducers|null */
    protected $argumentReducers = null;

    /** @var bool */
    protected $withObject = false;

    /** @var bool */
    protected $trimFilePaths = false;

    /** @var string|null */
    protected $applicationPath;

    /** @var int */
    protected $offset = 0;

    /** @var int */
    protected $limit = 0;

    /** @var \Closure|null */
    protected $startingFromFrameClosure = null;

    /** @var \Throwable|null */
    protected $throwable = null;

    public static function create(): self
    {
        return new static();
    }

    public static function createForThrowable(Throwable $throwable): self
    {
        return (new static())->forThrowable($throwable);
    }

    protected function forThrowable(Throwable $throwable): self
    {
        $this->throwable = $throwable;

        return $this;
    }

    public function withArguments(
        bool $withArguments = true
    ): self {
        $this->withArguments = $withArguments;

        return $this;
    }

    /**
     * @param array<class-string<ArgumentReducer>|ArgumentReducer>|ArgumentReducers|null $argumentReducers
     *
     * @return $this
     */
    public function reduceArguments(
        $argumentReducers = null
    ): self {
        $this->reduceArguments = true;
        $this->argumentReducers = $argumentReducers;

        return $this;
    }

    public function withObject(bool $withObject = true): self
    {
        $this->withObject = $withObject;

        return $this;
    }

    public function applicationPath(string $applicationPath): self
    {
        $this->applicationPath = rtrim($applicationPath, '/');

        return $this;
    }

    public function trimFilePaths(): self
    {
        $this->trimFilePaths = true;

        return $this;
    }

    public function offset(int $offset): self
    {
        $this->offset = $offset;

        return $this;
    }

    public function limit(int $limit): self
    {
        $this->limit = $limit;

        return $this;
    }

    public function startingFromFrame(Closure $startingFromFrameClosure)
    {
        $this->startingFromFrameClosure = $startingFromFrameClosure;

        return $this;
    }

    /**
     * @return \Spatie\Backtrace\Frame[]
     */
    public function frames(): array
    {
        $rawFrames = $this->getRawFrames();

        return $this->toFrameObjects($rawFrames);
    }

    public function firstApplicationFrameIndex(): ?int
    {
        foreach ($this->frames() as $index => $frame) {
            if ($frame->applicationFrame) {
                return $index;
            }
        }

        return null;
    }

    protected function getRawFrames(): array
    {
        if ($this->throwable) {
            return $this->throwable->getTrace();
        }

        // Omit arguments and object
        $options = DEBUG_BACKTRACE_IGNORE_ARGS;

        // Populate arguments
        if ($this->withArguments) {
            $options = 0;
        }

        // Populate object
        if ($this->withObject) {
            $options = $options | DEBUG_BACKTRACE_PROVIDE_OBJECT;
        }

        $limit = $this->limit;

        if ($limit !== 0) {
            $limit += 3;
        }

        return debug_backtrace($options, $limit);
    }

    /**
     * @return \Spatie\Backtrace\Frame[]
     */
    protected function toFrameObjects(array $rawFrames): array
    {
        $currentFile = $this->throwable ? $this->throwable->getFile() : '';
        $currentLine = $this->throwable ? $this->throwable->getLine() : 0;
        $arguments = $this->withArguments ? [] : null;

        $frames = [];

        $reduceArgumentsAction = new ReduceArgumentsAction($this->resolveArgumentReducers());

        foreach ($rawFrames as $rawFrame) {
            $textSnippet = null;

            if (
                class_exists(ClosureStream::class)
                && substr($currentFile, 0, strlen(ClosureStream::STREAM_PROTO)) === ClosureStream::STREAM_PROTO
            ) {
                $textSnippet = $currentFile;
                $currentFile = ClosureStream::STREAM_PROTO.'://function()';
                $currentLine -= 1;
            }

            if ($this->trimFilePaths && $this->applicationPath) {
                $trimmedFilePath = str_replace($this->applicationPath, '', $currentFile);
            }
            $frame = new Frame(
                $currentFile,
                $currentLine,
                $arguments,
                $rawFrame['function'] ?? null,
                $rawFrame['class'] ?? null,
                $rawFrame['object'] ?? null,
                $this->isApplicationFrame($currentFile),
                $textSnippet,
                $trimmedFilePath ?? null,
            );

            $frames[] = $frame;

            $arguments = $this->withArguments
                ? $rawFrame['args'] ?? null
                : null;

            if ($this->reduceArguments) {
                $arguments = $reduceArgumentsAction->execute(
                    $rawFrame['class'] ?? null,
                    $rawFrame['function'] ?? null,
                    $arguments
                );
            }

            $currentFile = $rawFrame['file'] ?? 'unknown';
            $currentLine = $rawFrame['line'] ?? 0;
        }

        $frames[] = new Frame(
            $currentFile,
            $currentLine,
            [],
            '[top]',
            null,
            null,
            $this->isApplicationFrame($currentFile),
        );

        $frames = $this->removeBacktracePackageFrames($frames);

        if ($closure = $this->startingFromFrameClosure) {
            $frames = $this->startAtFrameFromClosure($frames, $closure);
        }
        $frames = array_slice($frames, $this->offset, $this->limit === 0 ? PHP_INT_MAX : $this->limit);

        return array_values($frames);
    }

    protected function isApplicationFrame(string $frameFilename): bool
    {
        $relativeFile = str_replace('\\', DIRECTORY_SEPARATOR, $frameFilename);

        if (! empty($this->applicationPath)) {
            $relativeFile = array_reverse(explode($this->applicationPath ?? '', $frameFilename, 2))[0];
        }

        if (strpos($relativeFile, DIRECTORY_SEPARATOR.'vendor') === 0) {
            return false;
        }

        // Edge case for vendor files that typically live in the app code (e.g. Laravel's `artisan` or Statamic's `please`)
        if (preg_match('/\/(artisan|please)$/', $relativeFile)) {
            return false;
        }

        return true;
    }

    protected function removeBacktracePackageFrames(array $frames): array
    {
        return $this->startAtFrameFromClosure($frames, function (Frame $frame) {
            return $frame->class !== static::class;
        });
    }

    /**
     * @param \Spatie\Backtrace\Frame[] $frames
     * @param \Closure $closure
     *
     * @return array
     */
    protected function startAtFrameFromClosure(array $frames, Closure $closure): array
    {
        foreach ($frames as $i => $frame) {
            $foundStartingFrame = $closure($frame);

            if ($foundStartingFrame) {
                return $frames;
            }

            unset($frames[$i]);
        }

        return $frames;
    }

    protected function resolveArgumentReducers(): ArgumentReducers
    {
        if ($this->argumentReducers === null) {
            return ArgumentReducers::default();
        }

        if ($this->argumentReducers instanceof ArgumentReducers) {
            return $this->argumentReducers;
        }

        return ArgumentReducers::create($this->argumentReducers);
    }
}
