<?php

declare(strict_types=1);

namespace Plugin\tnt_stabila_shop\src\Services;

use DateTime;
use JTL\Cache\JTLCacheInterface;
use JTL\Cart\CartItem;
use JTL\Helpers\Request;
use JTL\Shop;
use Plugin\tnt_stabila_shop\src\DomainObjects\PerformanceDTO;
use Plugin\tnt_stabila_shop\src\DomainObjects\StabilaEventDTO;
use Plugin\tnt_stabila_shop\src\DomainObjects\StabilaRequestDTO;
use Plugin\tnt_stabila_shop\src\Enums\PageType;
use Plugin\tnt_stabila_shop\src\Helper\AlertChannelRouter;
use Plugin\tnt_stabila_shop\src\Helper\AlertScheduleDecider;
use Plugin\tnt_stabila_shop\src\Helper\AlertSeverityDecider;
use Plugin\tnt_stabila_shop\src\Helper\EventThrottleDecider;
use Plugin\tnt_stabila_shop\src\Helper\ShopRuntime;
use Plugin\tnt_stabila_shop\src\Helper\WebhookPayloadBuilder;
use Plugin\tnt_stabila_shop\src\Http\SpoolingHttpClient;
use Plugin\tnt_stabila_shop\src\Repositories\DatabaseRepository;
use Plugin\tnt_stabila_shop\src\Repositories\ReportRepository;
use Plugin\tnt_stabila_shop\src\Repositories\SentrySpoolRepository;
use Plugin\tnt_stabila_shop\src\Repositories\SessionRepository;
use Sentry\Event;
use Sentry\Frame;
use Sentry\Severity;
use Throwable;

use function Sentry\captureException as sentryCaptureException;
use function Sentry\init as SentryInit;

class SentryService
{
    protected const SECONDS_BETWEEN_EVENTS_THRESHOLD = 60;
    protected const PERFORMANCE_PACKAGE_SIZE         = 15;
    /** Hard cap (seconds) on the synchronous, in-request Sentry send so a slow DSN can't stall a page. */
    protected const SENTRY_HTTP_TIMEOUT_SECONDS         = 2;
    protected const SENTRY_HTTP_CONNECT_TIMEOUT_SECONDS = 2;
    /** Hard cap (seconds) on the fire-and-forget webhook POST so a dead endpoint never stalls the request. */
    protected const WEBHOOK_TIMEOUT_SECONDS = 3;

    protected ReportService $reportService;

    /** True only when a merchant DSN was supplied and Sentry was initialised. No DSN => error tracking is fully OFF. */
    protected bool $enabled = false;

    public function __construct(
        protected DatabaseRepository $database,
        protected SessionRepository $session,
        protected JTLCacheInterface|null $cache = null,
        // Merchant-supplied Sentry DSN (managed in the Dashboard's Quality Booster block). It is
        // MANDATORY: errors are reported only to the merchant's own Sentry project. When empty,
        // Sentry is not initialised and no error/exception handlers are registered.
        ?string $dsn = null
    ) {
        $this->reportService = new ReportService(
            new ReportRepository(Shop::Container()->getDB())
        );
        $this->cache         = $cache ?? Shop::Container()->getCache();
        $dsn                 = $dsn !== null ? trim($dsn) : '';
        if ($dsn === '') {
            return;
        }
        SentryInit([
            'dsn'               => $dsn,
            'environment'       => ShopRuntime::applicationBuildSha() === '#DEV#' ? 'development' : 'production',
            'attach_stacktrace' => true,
            'server_name'       => ShopRuntime::shopUrl(),
            'error_types'       => \E_ALL,
            'before_send'       => $this->sentryBeforeSend(...),
            // Do NOT POST to Sentry inside the request: the SpoolingHttpClient persists the built
            // envelope to a local table instead, so a capture costs one INSERT regardless of the
            // environment (no fpm/module dependency). The tnt_stabila_sentry_spool cron drains it
            // out-of-band via the SDK's real client. The timeouts below are a safety net in case
            // the spooling client is ever bypassed and the SDK falls back to a direct send.
            'http_timeout'         => self::SENTRY_HTTP_TIMEOUT_SECONDS,
            'http_connect_timeout' => self::SENTRY_HTTP_CONNECT_TIMEOUT_SECONDS,
            'http_client'          => new SpoolingHttpClient(
                new SentrySpoolRepository(Shop::Container()->getDB())
            ),
        ]);
        $this->enabled = true;
    }

    /**
     * @param array<string, mixed> $args
     */
    public function saveOrderData(array $args): void
    {
        $order    = $args['oBestellung'] ?? null;
        $orderID  = (int)($args['bestellID']->kBestellung ?? null);
        $referrer = $this->session->getReferrer();
        if ($referrer === '' || $orderID <= 0) {
            return;
        }
        $this->database->saveOrderData((object)[
            'orderID'         => $orderID,
            'referrer'        => $referrer,
            'totalOrderValue' => $order->fGesamtsumme ?? 0.0,
        ]);
    }

    public function collectData(): void
    {
        if ($this->session->isBot() || Request::isAjaxRequest()) {
            return;
        }
        $this->saveReferrer();
        $this->doPerformanceAnalytics();
    }

    public function saveReferrer(): void
    {
        if (isset($_SERVER['HTTP_REFERER'])) {
            $referrerDomain = \parse_url($_SERVER['HTTP_REFERER'])['host'] ?? '';
            $referrerDomain = \str_replace('www.', '', $referrerDomain);
            if ($referrerDomain !== '' && $this->session->getReferrer() === '') {
                $this->session->setReferrer($referrerDomain);
            }
        }
        if (isset($referrerDomain) === false && isset($_SERVER['HTTP_USER_AGENT'])) {
            $userAgent = match (true) {
                \str_contains($_SERVER['HTTP_USER_AGENT'], 'Android') => 'Android',
                \str_contains($_SERVER['HTTP_USER_AGENT'], 'iPod')    => 'iOS',
                \str_contains($_SERVER['HTTP_USER_AGENT'], 'iPhone')  => 'iOS',
                \str_contains($_SERVER['HTTP_USER_AGENT'], 'iPad')    => 'iOS',
                default                                               => 'Unknown',
            };
            $this->session->setReferrer($userAgent);
        }
    }

    public function doPerformanceAnalytics(): void
    {
        global $nStartzeit;
        $performanceDTO = new PerformanceDTO(
            totalExecutionInSeconds: $nStartzeit === null ? 0 : (\microtime(true) - $nStartzeit),
            pageType: PageType::fromValue(
                (int)(Shop::Smarty()->getTemplateVars('nSeitenTyp') ?? '0')
            )
        );
        $this->session->appendPerformance($performanceDTO);
        $performanceData = $this->session->getPerformance();
        if (\count($performanceData) >= self::PERFORMANCE_PACKAGE_SIZE) {
            $accumulatedData = [];
            foreach ($performanceData as $dataChunk) {
                if (isset($accumulatedData[$dataChunk->pageType->value]) === false) {
                    $accumulatedData[$dataChunk->pageType->value] = [0.0, 0];
                }
                $accumulatedData[$dataChunk->pageType->value] = [
                    $dataChunk->pageType,
                    $accumulatedData[$dataChunk->pageType->value][1] + $dataChunk->totalExecutionInSeconds,
                    \count($accumulatedData[$dataChunk->pageType->value])
                ];
            }
            $performanceData = [];
            foreach ($accumulatedData as $dataChunk) {
                $performanceData[] = new PerformanceDTO(
                    totalExecutionInSeconds: $dataChunk[1] / $dataChunk[2],
                    pageType: $dataChunk[0],
                );
            }
            $this->database->savePerformanceData($performanceData);
            $this->session->unsetPerformance();
        }
    }

    /**
     * @return object{link: string, maxDate: string}|null
     */
    public function createServiceReport(string $filePath, string $functionName): object|null
    {
        $reportName = \md5($filePath . '_' . $functionName);
        if ($this->reportService->reportAlreadyExists($reportName) === false) {
            return null;
        }
        try {
            $report = $this->reportService->generateAndSaveReport($reportName, Shop::getAdminURL());
        } catch (\Exception) {
            return null;
        }

        return $report;
    }

    public function sentryBeforeSend(Event $event): Event|null
    {
        if ($this->session->isBot()) {
            return null;
        }
        global $nStartzeit;
        $lastEventTimeStamp = $this->session->getLastEventTimeStamp();
        if (
            EventThrottleDecider::shouldDrop(
                $lastEventTimeStamp,
                (float)$event->getTimestamp(),
                self::SECONDS_BETWEEN_EVENTS_THRESHOLD
            )
        ) {
            // Do not forward multiple events in a short time frame. Each forward is a
            // synchronous HTTP POST, so an in-request burst would otherwise stall the shop.
            return null;
        }
        $exception = $event->getExceptions()[0] ?? null;
        if ($exception === null) {
            return $event;
        }
        $exceptionFrames = $exception->getStacktrace()?->getFrames() ?? [];
        $lastFrame       = \end($exceptionFrames);
        $filePath        = $lastFrame instanceof Frame ? $lastFrame->getFile() : '';
        $functionName    = $lastFrame instanceof Frame ? ($lastFrame->getFunctionName() ?? '') : '';
        if ($event->getLevel() === null) {
            $event->setLevel(Severity::fromError(\E_ERROR));
        }

        $event->setTags([
            'domain'         => ShopRuntime::shopUrl(),
            'shopVersion'    => ShopRuntime::applicationVersion(),
            'activeTemplate' => $this->session->getActiveTemplate(),
            'severity'       => (string)$event->getLevel(),
            'filePath'       => $filePath,
            'functionName'   => $functionName,
        ]);
        $settingService = new SettingsService(
            new DatabaseRepository(Shop::Container()->getDB()),
            new SessionRepository('tnt_stabila_shop'),
            Shop::Container()->getCache()
        );
        $serviceReport  = null;
        if ($settingService->getSetting('autoReport') === 1) {
            $serviceReport = $this->createServiceReport($filePath, $functionName);
        }
        try {
            $appState = \json_encode(Shop::getParameters(), JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            $appState = '';
        }
        $dbVersion = Shop::getShopDatabaseVersion();
        $event->setExtra(\array_merge(
            [
                'shopVersion'     => ShopRuntime::applicationVersion(),
                'databaseVersion' => $dbVersion->getMajor()
                    . '.' . $dbVersion->getMinor() . '.' . $dbVersion->getPatch(),
                'reportLink'      => $serviceReport->link ?? '',
                'reportMaxDate'   => $serviceReport->maxDate ?? '',
                'totalExecTime'   => $nStartzeit === null ? 0 : (\microtime(true) - $nStartzeit),
                'entryPoint'      => Shop::getEntryPoint(),
                'pageType'        => PageType::fromValue(Shop::getPageType())->value,
                'appState'        => $appState,
                'shopLanguage'    => $this->session->getCurrentLanguage(),
                'shopCurrency'    => $this->session->getCurrentCurrency(),
                'cartCount'       => $this->session->getCartItemCount(),
                'cartItems'       => \implode(
                    ', ',
                    \array_map(
                        static fn (CartItem $item): string => $item->nAnzahl
                            . ' x ' . $item->getName() . ' (' . $item->fPreisEinzelNetto . ')',
                        $this->session->getCartItems()
                    )
                ),
                'activeTemplate'  => $this->session->getActiveTemplate(),
                'deliveryCountry' => $this->session->getDeliveryCountry(),
                'invoiceCountry'  => $this->session->getInvoiceCountry(),
                'taxRates'        => \implode(', ', $this->session->getTaxRates()),
                'userBrowser'     => $this->session->getUserBrowser(),
                'userID'          => $this->session->getUserID(),
            ],
            $event->getExtra()
        ));
        $errorService = new ErrorService(
            new DatabaseRepository(Shop::Container()->getDB()),
            new SessionRepository('tnt_stabila_shop'),
            Shop::Container()->getCache()
        );
        if ($settingService->getSetting('qualityBooster')) {
            $minSeverity = $settingService->getSetting('alertMinSeverity');
            $quietFrom   = $settingService->getSetting('alertQuietFrom');
            $quietUntil  = $settingService->getSetting('alertQuietUntil');
            if (
                AlertSeverityDecider::shouldAlert(
                    (string)$event->getLevel(),
                    \is_int($minSeverity) ? $minSeverity : null
                )
                && AlertScheduleDecider::shouldAlert(
                    (int)\date('G'),
                    \is_int($quietFrom) ? $quietFrom : null,
                    \is_int($quietUntil) ? $quietUntil : null
                )
            ) {
                $channels = AlertChannelRouter::route(
                    (string)$event->getLevel(),
                    $this->alertChannelConfig($settingService)
                );
                $errorService->notifyAdminAboutError(
                    $this->convertSentryEventToStabila($event),
                    \in_array(AlertChannelRouter::CHANNEL_MAIL, $channels, true)
                );
                if (\in_array(AlertChannelRouter::CHANNEL_WEBHOOK, $channels, true)) {
                    // Fire-and-forget side channel: independent of the mail/notification
                    // write above, and guarded so a dead webhook can never break this path.
                    $this->dispatchWebhook($settingService, $event);
                }
            }
        }
        $this->session->setLastEventTimeStamp((float)$event->getTimestamp());

        return $this->database->saveEvent($event, $this->session->getUserIP()) ? $event : null;
    }

    public function sentryCapture(Throwable $exception): void
    {
        sentryCaptureException($exception);
        restore_exception_handler();
        throw $exception;
    }

    public function sentryErrorHandler(int $type, string $message, string $file, int $line): bool
    {
        if (!(\error_reporting() & $type)) {
            return false;
        }
        $exception = new \ErrorException(
            message: $message,
            code: 0,
            severity: $type,
            filename: $file,
            line: $line
        );
        sentryCaptureException($exception);
        restore_error_handler();

        return true;
    }

    public function setSentryHandlers(): void
    {
        if ($this->enabled === false) {
            return;
        }
        \set_exception_handler($this->sentryCapture(...));
        \set_error_handler($this->sentryErrorHandler(...));
    }

    public function addNotificationsToSession(): void
    {
        $notifications = $this->database->getNotifications();
        foreach ($notifications as $notification) {
            /** @var object{type: int, title: string, description: string,
             * hash: string, createDate: DateTime} $notification */
            $this->database->addNotificationsToSession(
                $notification->type,
                $notification->title,
                $notification->description,
                '',
                $notification->hash,
            );
        }
    }

    /**
     * Build the per-channel minimum-severity map consulted by {@see AlertChannelRouter}.
     *
     * The in-shop notification channel preserves the historic "always notify"
     * behaviour once the global severity/quiet-hours gate has passed. The email
     * channel is disabled unless the mailNotifications toggle is on, and then
     * honours its own configurable minimum severity (channelMailMinSeverity),
     * defaulting to 0 so existing installs keep mailing every gated alert.
     *
     * @return array<string, int|null>
     */
    private function alertChannelConfig(SettingsService $settingService): array
    {
        $mailEnabled = (bool)$settingService->getSetting('mailNotifications');
        $mailMin     = $settingService->getSetting('channelMailMinSeverity');
        $webhookUrl  = $settingService->getSetting('channelWebhookUrl');
        $webhookMin  = $settingService->getSetting('channelWebhookMinSeverity');
        // The webhook channel is enabled ONLY when a non-empty URL is configured;
        // an empty/blank URL leaves it disabled (null), so existing installs are unaffected.
        $webhookEnabled = \is_string($webhookUrl) && \trim($webhookUrl) !== '';

        return [
            AlertChannelRouter::CHANNEL_NOTIFICATION => AlertSeverityDecider::RANK_DEBUG,
            AlertChannelRouter::CHANNEL_MAIL         => $mailEnabled
                ? (\is_int($mailMin) ? $mailMin : AlertSeverityDecider::RANK_DEBUG)
                : null,
            AlertChannelRouter::CHANNEL_WEBHOOK      => $webhookEnabled
                ? (\is_int($webhookMin) ? $webhookMin : AlertSeverityDecider::RANK_ERROR)
                : null,
        ];
    }

    /**
     * Build the webhook payload and hand it to the guarded sender.
     *
     * Reads the merchant-configured URL, derives a human-readable message from the
     * event, and POSTs the deterministic {@see WebhookPayloadBuilder} payload. A
     * blank URL is a no-op. This never throws — see {@see self::postJson()}.
     */
    private function dispatchWebhook(SettingsService $settingService, Event $event): void
    {
        $url = $settingService->getSetting('channelWebhookUrl');
        if (\is_string($url) === false || \trim($url) === '') {
            return;
        }
        $exception = $event->getExceptions()[0] ?? null;
        $message   = $exception?->getValue() ?? ($event->getMessage() ?? '');
        $payload   = WebhookPayloadBuilder::build(
            (string)$event->getLevel(),
            $message,
            ShopRuntime::shopUrl(),
            (int)(float)$event->getTimestamp(),
            ['shopVersion' => ShopRuntime::applicationVersion()]
        );
        $this->postJson(\trim($url), $payload);
    }

    /**
     * Guarded, non-throwing JSON POST used as a fire-and-forget alert side channel.
     *
     * A short timeout bounds the request, and every failure (encoding, transport,
     * unreachable endpoint) is swallowed so the webhook can NEVER break the request
     * or the existing mail/notification path.
     *
     * @param array<string, mixed> $payload
     */
    private function postJson(string $url, array $payload): void
    {
        try {
            $body   = \json_encode($payload, JSON_THROW_ON_ERROR);
            $handle = \curl_init($url);
            if ($handle === false) {
                return;
            }
            \curl_setopt_array($handle, [
                \CURLOPT_POST           => true,
                \CURLOPT_POSTFIELDS     => $body,
                \CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
                \CURLOPT_TIMEOUT        => self::WEBHOOK_TIMEOUT_SECONDS,
                \CURLOPT_CONNECTTIMEOUT => self::WEBHOOK_TIMEOUT_SECONDS,
                \CURLOPT_RETURNTRANSFER => true,
            ]);
            \curl_exec($handle);
            \curl_close($handle);
        } catch (\Throwable) {
            // Fire-and-forget: swallow everything so a dead webhook is invisible to the request.
        }
    }

    private function convertSentryEventToStabila(Event $event): StabilaEventDTO
    {
        $extra   = $event->getExtra();
        $request = $event->getRequest();
        try {
            $appState = \json_decode($extra['appState'], false, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            $appState = null;
        }
        $exception       = $event->getExceptions()[0] ?? null;
        $exceptionFrames = $exception?->getStacktrace()?->getFrames() ?? [];
        $lastFrame       = \end($exceptionFrames);
        $filePath        = $lastFrame instanceof Frame ? $lastFrame->getFile() : '';
        $functionName    = $lastFrame instanceof Frame ? ($lastFrame->getFunctionName() ?? '') : '';

        return new StabilaEventDTO(
            sentryEventID: (string)$event->getId(),
            shopVersion: $extra['shopVersion'] ?? '',
            databaseVersion: $extra['databaseVersion'] ?? '',
            activeTemplate: $extra['activeTemplate'] ?? '',
            filePath: $filePath,
            functionName: $functionName,
            totalExecTime: (float)($extra['totalExecTime'] ?? 0.0),
            pageType: PageType::fromValue(Shop::getPageType()),
            appState: \is_object($appState) ? $appState : (object)[],
            shopLanguage: $extra['shopLanguage'] ?? '',
            shopCurrency: $extra['shopCurrency'] ?? '',
            cartCount: $extra['cartCount'] ?? 0,
            cartItems: $extra['cartItems'] ?? '',
            deliveryCountry: $extra['deliveryCountry'] ?? '',
            invoiceCountry: $extra['invoiceCountry'] ?? '',
            taxRates: $extra['taxRates'] ?? '',
            userBrowser: $extra['userBrowser'] ?? '',
            userID: $extra['userID'] ?? 0,
            request: new StabilaRequestDTO(
                url: $request['url'] ?? '',
                method: $request['method'] ?? '',
                queryString: $request['query_string'] ?? '',
            ),
            exceptions: $event->getExceptions(),
            eventType: $event->getType(),
            severity: $event->getLevel() ?? Severity::fromError(\E_ERROR),
            timestamp: (float)$event->getTimestamp()
        );
    }
}
