<?php

declare(strict_types=1);

namespace Sentry;

use Sentry\Serializer\RepresentationSerializerInterface;

/**
 * This class builds a {@see Frame} object out of a backtrace's raw frame.
 *
 * @internal
 */
final class FrameBuilder
{
    /**
     * @var Options The SDK client options
     */
    private $options;

    /**
     * @var RepresentationSerializerInterface The representation serializer
     */
    private $representationSerializer;

    /**
     * Constructor.
     *
     * @param Options                           $options                  The SDK client options
     * @param RepresentationSerializerInterface $representationSerializer The representation serializer
     */
    public function __construct(Options $options, RepresentationSerializerInterface $representationSerializer)
    {
        $this->options = $options;
        $this->representationSerializer = $representationSerializer;
    }

    /**
     * Builds a {@see Frame} object from the given backtrace's raw frame.
     *
     * @param string               $file           The file where the frame originated
     * @param int                  $line           The line at which the frame originated
     * @param array<string, mixed> $backtraceFrame The raw frame
     *
     * @psalm-param array{
     *     function?: callable-string,
     *     line?: integer,
     *     file?: string,
     *     class?: class-string,
     *     type?: string,
     *     args?: array
     * } $backtraceFrame
     */
    public function buildFromBacktraceFrame(string $file, int $line, array $backtraceFrame): Frame
    {
        // The filename can be in any of these formats:
        //   - </path/to/filename>
        //   - </path/to/filename>(<line number>) : eval()'d code
        //   - </path/to/filename>(<line number>) : runtime-created function
        if (preg_match('/^(.*)\((\d+)\) : (?:eval\(\)\'d code|runtime-created function)$/', $file, $matches)) {
            $file = $matches[1];
            $line = (int) $matches[2];
        }

        $functionName = null;
        $rawFunctionName = null;
        $strippedFilePath = $this->stripPrefixFromFilePath($file);

        if (isset($backtraceFrame['class']) && isset($backtraceFrame['function'])) {
            $functionName = $backtraceFrame['class'];

            if (str_starts_with($functionName, Frame::ANONYMOUS_CLASS_PREFIX)) {
                $functionName = Frame::ANONYMOUS_CLASS_PREFIX . $this->stripPrefixFromFilePath(substr($backtraceFrame['class'], \strlen(Frame::ANONYMOUS_CLASS_PREFIX)));
            }

            $rawFunctionName = sprintf('%s::%s', $backtraceFrame['class'], $backtraceFrame['function']);
            $functionName = sprintf('%s::%s', preg_replace('/0x[a-fA-F0-9]+$/', '', $functionName), $backtraceFrame['function']);
        } elseif (isset($backtraceFrame['function'])) {
            $functionName = $backtraceFrame['function'];
        }

        return new Frame(
            $functionName,
            $strippedFilePath,
            $line,
            $rawFunctionName,
            Frame::INTERNAL_FRAME_FILENAME !== $file ? $file : null,
            $this->getFunctionArguments($backtraceFrame),
            $this->isFrameInApp($file, $functionName)
        );
    }

    /**
     * Removes from the given file path the specified prefixes.
     *
     * @param string $filePath The path to the file
     */
    private function stripPrefixFromFilePath(string $filePath): string
    {
        foreach ($this->options->getPrefixes() as $prefix) {
            if (str_starts_with($filePath, $prefix)) {
                return mb_substr($filePath, mb_strlen($prefix));
            }
        }

        return $filePath;
    }

    /**
     * Checks whether a certain frame should be marked as "in app" or not.
     *
     * @param string      $file         The file to check
     * @param string|null $functionName The name of the function
     */
    private function isFrameInApp(string $file, ?string $functionName): bool
    {
        if (Frame::INTERNAL_FRAME_FILENAME === $file) {
            return false;
        }

        if (null !== $functionName && str_starts_with($functionName, 'Sentry\\')) {
            return false;
        }

        $excludedAppPaths = $this->options->getInAppExcludedPaths();
        $includedAppPaths = $this->options->getInAppIncludedPaths();
        $absoluteFilePath = @realpath($file) ?: $file;
        $isInApp = true;

        foreach ($excludedAppPaths as $excludedAppPath) {
            if (str_starts_with($absoluteFilePath, $excludedAppPath)) {
                $isInApp = false;

                break;
            }
        }

        foreach ($includedAppPaths as $includedAppPath) {
            if (str_starts_with($absoluteFilePath, $includedAppPath)) {
                $isInApp = true;

                break;
            }
        }

        return $isInApp;
    }

    /**
     * Gets the arguments of the function called in the given frame.
     *
     * @param array<string, mixed> $backtraceFrame The frame data
     *
     * @return array<string, mixed>
     *
     * @throws \ReflectionException
     *
     * @psalm-param array{
     *     function?: callable-string,
     *     class?: class-string,
     *     type?: string,
     *     args?: array
     * } $backtraceFrame
     */
    private function getFunctionArguments(array $backtraceFrame): array
    {
        if (!isset($backtraceFrame['function'], $backtraceFrame['args'])) {
            return [];
        }

        $reflectionFunction = null;

        try {
            if (isset($backtraceFrame['class'], $backtraceFrame['function'])) {
                if (method_exists($backtraceFrame['class'], $backtraceFrame['function'])) {
                    $reflectionFunction = new \ReflectionMethod($backtraceFrame['class'], $backtraceFrame['function']);
                } elseif (isset($backtraceFrame['type']) && '::' === $backtraceFrame['type']) {
                    $reflectionFunction = new \ReflectionMethod($backtraceFrame['class'], '__callStatic');
                } else {
                    $reflectionFunction = new \ReflectionMethod($backtraceFrame['class'], '__call');
                }
            } elseif (isset($backtraceFrame['function']) && !\in_array($backtraceFrame['function'], ['{closure}', '__lambda_func'], true) && \function_exists($backtraceFrame['function'])) {
                $reflectionFunction = new \ReflectionFunction($backtraceFrame['function']);
            }
        } catch (\ReflectionException $e) {
            // Reflection failed, we do nothing instead
        }

        $argumentValues = [];

        if (null !== $reflectionFunction) {
            $argumentValues = $this->getFunctionArgumentValues($reflectionFunction, $backtraceFrame['args']);
        } else {
            foreach ($backtraceFrame['args'] as $parameterPosition => $parameterValue) {
                $argumentValues['param' . $parameterPosition] = $parameterValue;
            }
        }

        foreach ($argumentValues as $argumentName => $argumentValue) {
            $argumentValues[$argumentName] = $this->representationSerializer->representationSerialize($argumentValue);
        }

        return $argumentValues;
    }

    /**
     * Gets an hashmap indexed by argument name containing all the arguments
     * passed to the function called in the given frame of the stacktrace.
     *
     * @param \ReflectionFunctionAbstract $reflectionFunction A reflection object
     * @param mixed[]                     $backtraceFrameArgs The arguments of the frame
     *
     * @return array<string, mixed>
     */
    private function getFunctionArgumentValues(\ReflectionFunctionAbstract $reflectionFunction, array $backtraceFrameArgs): array
    {
        $argumentValues = [];

        foreach ($reflectionFunction->getParameters() as $reflectionParameter) {
            $parameterPosition = $reflectionParameter->getPosition();

            if (!isset($backtraceFrameArgs[$parameterPosition])) {
                continue;
            }

            $argumentValues[$reflectionParameter->getName()] = $backtraceFrameArgs[$parameterPosition];
        }

        return $argumentValues;
    }
}
