<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Webhook;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;

/**
 * Parses a Brevo Inbound Parse webhook request into one {@see RemoteEvent} per
 * email — handled by {@see \Ferienpass\CoreBundle\RemoteEvent\InboundEmailConsumer}.
 *
 * This is the inbound counterpart to the app's `mailer_brevo` engagement webhook;
 * Symfony's bundled Brevo parser only understands engagement events, so the
 * Inbound Parse payload needs its own parser. Routing is registered by the core
 * bundle (FerienpassCoreExtension::prepend) at `/webhook/brevo_inbound`.
 *
 * Security: fail-closed on a missing/mismatched URL secret (`?secret=…`); the
 * HMAC-signed reply token is verified later by the consumer.
 */
final class BrevoInboundRequestParser extends AbstractRequestParser
{
    public function __construct(
        #[Autowire(param: 'ferienpass.inbound_secret')]
        private readonly ?string $inboundSecret = null,
    ) {
    }

    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new MethodRequestMatcher('POST'),
        ]);
    }

    /**
     * @return array<int, RemoteEvent>
     */
    protected function doParse(Request $request, #[\SensitiveParameter] string $secret): array
    {
        if (null === $this->inboundSecret || '' === $this->inboundSecret
            || !hash_equals($this->inboundSecret, (string) $request->query->get('secret', ''))
        ) {
            throw new RejectWebhookException(403, 'Invalid or missing inbound secret.');
        }

        try {
            $payload = json_decode($request->getContent(), true, 64, \JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new RejectWebhookException(406, 'Payload is malformed.', $e);
        }

        $items = $payload['items'] ?? (isset($payload['From']) ? [$payload] : []);
        if (!\is_array($items)) {
            throw new RejectWebhookException(406, 'Payload is malformed.');
        }

        $events = [];
        foreach ($items as $index => $item) {
            if (!\is_array($item)) {
                continue;
            }

            $token = $this->extractToken($item);
            $text = $this->extractText($item);
            $attachments = $this->extractAttachments($item);
            if (null === $token || ('' === trim($text) && [] === $attachments)) {
                // Not addressed to a conversation, or nothing to act on — ignore.
                continue;
            }

            $events[] = new RemoteEvent('brevo_inbound', (string) ($item['MessageId'] ?? $token.'-'.$index), [
                'token' => $token,
                'fromEmail' => $this->extractFromEmail($item),
                'fromName' => $this->extractFromName($item),
                'text' => $text,
                'attachments' => $attachments,
            ]);
        }

        return $events;
    }

    /**
     * The reply token sits in the plus-addressed recipient (`kommentar+<token>@…`).
     *
     * @param array<string, mixed> $item
     */
    private function extractToken(array $item): ?string
    {
        foreach (['To', 'Cc'] as $field) {
            foreach ((array) ($item[$field] ?? []) as $recipient) {
                $address = \is_array($recipient) ? ($recipient['Address'] ?? '') : (string) $recipient;
                if (1 === preg_match('/\+([^@]+)@/', (string) $address, $m)) {
                    return $m[1];
                }
            }
        }

        return null;
    }

    /**
     * @param array<string, mixed> $item
     */
    private function extractFromEmail(array $item): ?string
    {
        $from = $item['From'] ?? null;
        $email = \is_array($from) ? ($from['Address'] ?? null) : (\is_string($from) ? $from : null);

        return null !== $email && '' !== $email ? mb_strtolower(trim($email)) : null;
    }

    /**
     * @param array<string, mixed> $item
     */
    private function extractFromName(array $item): ?string
    {
        $from = $item['From'] ?? null;

        return \is_array($from) ? ($from['Name'] ?? null) : null;
    }

    /**
     * The reply text without quoted history. Brevo already extracts it as
     * `ExtractedMarkdownMessage`; otherwise strip the quote ourselves.
     *
     * @param array<string, mixed> $item
     */
    private function extractText(array $item): string
    {
        $extracted = trim((string) ($item['ExtractedMarkdownMessage'] ?? ''));
        if ('' !== $extracted) {
            return $extracted;
        }

        return $this->stripQuotedText((string) ($item['RawTextBody'] ?? ''));
    }

    /**
     * The attachment descriptors Brevo lists on the email. Contents are not
     * inlined — each carries a `DownloadToken` fetched later
     * ({@see \Ferienpass\CoreBundle\Comment\BrevoAttachmentDownloader}); entries
     * without one are unusable and dropped here.
     *
     * @param array<string, mixed> $item
     *
     * @return array<int, array{token: string, name: string, mime: ?string, size: int}>
     */
    private function extractAttachments(array $item): array
    {
        $result = [];
        foreach ((array) ($item['Attachments'] ?? []) as $attachment) {
            if (!\is_array($attachment)) {
                continue;
            }

            $token = (string) ($attachment['DownloadToken'] ?? '');
            if ('' === $token) {
                continue;
            }

            $result[] = [
                'token' => $token,
                'name' => (string) ($attachment['Name'] ?? ''),
                'mime' => isset($attachment['ContentType']) ? (string) $attachment['ContentType'] : null,
                'size' => (int) ($attachment['ContentLength'] ?? 0),
            ];
        }

        return $result;
    }

    private function stripQuotedText(string $body): string
    {
        $lines = preg_split('/\r\n|\r|\n/', $body) ?: [];
        $kept = [];

        foreach ($lines as $line) {
            // Quote markers (German + English) — stop at the first one. The
            // "oberhalb dieser Linie" marker is the one our own emails embed.
            if (1 === preg_match('/^\s*>/', $line)
                || 1 === preg_match('/oberhalb dieser Linie/iu', $line)
                || 1 === preg_match('/^\s*Am .+ schrieb.*:\s*$/u', $line)
                || 1 === preg_match('/^\s*On .+ wrote.*:\s*$/u', $line)
                || 1 === preg_match('/^\s*-{2,}\s*Original(\s|-)Message\s*-{2,}/iu', $line)
                || 1 === preg_match('/^\s*Von\s*:.+/u', $line)
            ) {
                break;
            }
            $kept[] = $line;
        }

        return trim(implode("\n", $kept));
    }
}
