<?php

namespace Sentry\Laravel;

use DateTimeInterface;
use Monolog\DateTimeImmutable;
use Monolog\Logger;
use Monolog\LogRecord;
use Monolog\Formatter\LineFormatter;
use Monolog\Formatter\FormatterInterface;
use Monolog\Handler\AbstractProcessingHandler;
use Sentry\Breadcrumb;
use Sentry\Event;
use Sentry\Monolog\CompatibilityProcessingHandlerTrait;
use Sentry\Severity;
use Sentry\State\HubInterface;
use Sentry\State\Scope;
use Throwable;
use TypeError;

class SentryHandler extends AbstractProcessingHandler
{
    use CompatibilityProcessingHandlerTrait;

    /**
     * @var string the current application environment (staging|preprod|prod)
     */
    protected $environment;

    /**
     * @var string should represent the current version of the calling
     *             software. Can be any string (git commit, version number)
     */
    protected $release;

    /**
     * @var HubInterface the hub object that sends the message to the server
     */
    protected $hub;

    /**
     * @var FormatterInterface The formatter to use for the logs generated via handleBatch()
     */
    protected $batchFormatter;

    /**
     * Indicates if we should report exceptions, if `false` this handler will ignore records with an exception set in the context.
     *
     * @var bool
     */
    private $reportExceptions;

    /**
     * Indicates if we should use the formatted message instead of just the message.
     *
     * @var bool
     */
    private $useFormattedMessage;

    /**
     * @param HubInterface $hub
     * @param int          $level  The minimum logging level at which this handler will be triggered
     * @param bool         $bubble Whether the messages that are handled can bubble up the stack or not
     * @param bool         $reportExceptions
     * @param bool         $useFormattedMessage
     */
    public function __construct(HubInterface $hub, $level = Logger::DEBUG, bool $bubble = true, bool $reportExceptions = true, bool $useFormattedMessage = false)
    {
        parent::__construct($level, $bubble);

        $this->hub                 = $hub;
        $this->reportExceptions    = $reportExceptions;
        $this->useFormattedMessage = $useFormattedMessage;
    }

    /**
     * {@inheritdoc}
     */
    public function handleBatch(array $records): void
    {
        $level = $this->level;

        // filter records based on their level
        $records = array_filter(
            $records,
            function ($record) use ($level) {
                return $record['level'] >= $level;
            }
        );

        if (!$records) {
            return;
        }

        // the record with the highest severity is the "main" one
        $record = array_reduce(
            $records,
            function ($highest, $record) {
                if ($highest === null || $record['level'] > $highest['level']) {
                    return $record;
                }

                return $highest;
            }
        );

        // the other ones are added as a context item
        $logs = [];
        foreach ($records as $r) {
            $logs[] = $this->processRecord($r);
        }

        if ($logs) {
            $record['context']['logs'] = (string)$this->getBatchFormatter()->formatBatch($logs);
        }

        $this->handle($record);
    }

    /**
     * Sets the formatter for the logs generated by handleBatch().
     *
     * @param FormatterInterface $formatter
     *
     * @return \Sentry\Laravel\SentryHandler
     */
    public function setBatchFormatter(FormatterInterface $formatter): self
    {
        $this->batchFormatter = $formatter;

        return $this;
    }

    /**
     * Gets the formatter for the logs generated by handleBatch().
     */
    public function getBatchFormatter(): FormatterInterface
    {
        if (!$this->batchFormatter) {
            $this->batchFormatter = $this->getDefaultBatchFormatter();
        }

        return $this->batchFormatter;
    }

    /**
     * Translates Monolog log levels to Sentry Severity.
     *
     * @param int $logLevel
     *
     * @return \Sentry\Severity
     */
    protected function getLogLevel(int $logLevel): Severity
    {
        return $this->getSeverityFromLevel($logLevel);
    }

    /**
     * {@inheritdoc}
     * @suppress PhanTypeMismatchArgument
     */
    protected function doWrite($record): void
    {
        $exception = $record['context']['exception'] ?? null;
        $isException = $exception instanceof Throwable;
        unset($record['context']['exception']);

        if (!$this->reportExceptions && $isException) {
            return;
        }

        $this->hub->withScope(
            function (Scope $scope) use ($record, $isException, $exception) {
                $context = !empty($record['context']) && is_array($record['context'])
                    ? $record['context']
                    : [];

                if (!empty($context)) {
                    $this->consumeContextAndApplyToScope($scope, $context);
                }

                if (!empty($record['extra']) && is_array($record['extra'])) {
                    foreach ($record['extra'] as $key => $extra) {
                        $scope->setExtra($key, $extra);
                    }
                }

                $logger = !empty($context['logger']) && is_string($context['logger'])
                    ? $context['logger']
                    : null;
                unset($context['logger']);

                // At this point we consumed everything we could from the context
                // what remains we add as `log_context` to the event as a whole
                if (!empty($context)) {
                    $scope->setExtra('log_context', $context);
                }

                $scope->addEventProcessor(
                    function (Event $event) use ($record, $logger) {
                        $event->setLevel($this->getLogLevel($record['level']));
                        $event->setLogger($logger ?? $record['channel']);

                        if (!empty($this->environment) && !$event->getEnvironment()) {
                            $event->setEnvironment($this->environment);
                        }

                        if (!empty($this->release) && !$event->getRelease()) {
                            $event->setRelease($this->release);
                        }

                        if (isset($record['datetime']) && $record['datetime'] instanceof DateTimeInterface) {
                            $event->setTimestamp($record['datetime']->getTimestamp());
                        }

                        return $event;
                    }
                );

                if ($isException) {
                    $this->hub->captureException($exception);
                } else {
                    $this->hub->captureMessage(
                        $this->useFormattedMessage || empty($record['message'])
                            ? $record['formatted']
                            : $record['message']
                    );
                }
            }
        );
    }

    /**
     * {@inheritDoc}
     */
    protected function getDefaultFormatter(): FormatterInterface
    {
        return new LineFormatter('[%channel%] %message%');
    }

    /**
     * Gets the default formatter for the logs generated by handleBatch().
     *
     * @return FormatterInterface
     */
    protected function getDefaultBatchFormatter(): FormatterInterface
    {
        return new LineFormatter();
    }

    /**
     * Set the release.
     *
     * @param string $value
     *
     * @return self
     */
    public function setRelease($value): self
    {
        $this->release = $value;

        return $this;
    }

    /**
     * Set the current application environment.
     *
     * @param string $value
     *
     * @return self
     */
    public function setEnvironment($value): self
    {
        $this->environment = $value;

        return $this;
    }

    /**
     * Add a breadcrumb.
     *
     * @link https://docs.sentry.io/learn/breadcrumbs/
     *
     * @param \Sentry\Breadcrumb $crumb
     *
     * @return \Sentry\Laravel\SentryHandler
     */
    public function addBreadcrumb(Breadcrumb $crumb): self
    {
        $this->hub->addBreadcrumb($crumb);

        return $this;
    }

    /**
     * Consumes the context and applies it to the scope.
     *
     * @param \Sentry\State\Scope $scope
     * @param array               $context
     *
     * @return void
     */
    private function consumeContextAndApplyToScope(Scope $scope, array &$context): void
    {
        if (!empty($context['extra']) && is_array($context['extra'])) {
            foreach ($context['extra'] as $key => $value) {
                $scope->setExtra($key, $value);
            }

            unset($context['extra']);
        }

        if (!empty($context['tags']) && is_array($context['tags'])) {
            foreach ($context['tags'] as $tag => $value) {
                // Ignore tags with a value that is not a string or can be casted to a string
                if (!$this->valueCanBeString($value)) {
                    continue;
                }

                $scope->setTag($tag, (string)$value);
            }

            unset($context['tags']);
        }

        if (!empty($context['fingerprint']) && is_array($context['fingerprint'])) {
            $scope->setFingerprint($context['fingerprint']);

            unset($context['fingerprint']);
        }

        if (!empty($context['user']) && is_array($context['user'])) {
            try {
                $scope->setUser($context['user']);

                unset($context['user']);
            } catch (TypeError $e) {
                // In some cases the context can be invalid, in that case we ignore it and
                // choose to not send it to Sentry in favor of not breaking the application
            }
        }
    }

    /**
     * Check if the value passed can be cast to a string.
     *
     * @param mixed $value
     *
     * @return bool
     */
    private function valueCanBeString($value): bool
    {
        return is_string($value) || is_scalar($value) || (is_object($value) && method_exists($value, '__toString'));
    }
}