<?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\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpClientException;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * Registers this tenant's Brevo webhooks. Brevo has two independent kinds, and
 * we own one endpoint for each:
 *   - **engagement** (Brevo type `transactional`) → `/webhook/mailer_brevo`:
 *     delivery/open tracking of the mail we send; one per Brevo account.
 *   - **inbound** (Brevo type `inbound`, per receiving domain) →
 *     `/webhook/brevo_inbound`: replies to the reply subdomain, turned into
 *     conversation items; one per tenant reply-domain.
 *
 * Pick one or both (prompted, or `--webhook`). Each is looked up on Brevo first
 * and only created when missing — the two are fully independent, so registering
 * (or re-creating with `--force`) one never touches the other.
 *
 * If the app is behind HTTP basic auth, pass `--auth=user:pass`: Brevo supports
 * {@see https://developers.brevo.com/docs/secured-webhooks credentials embedded
 * in the URL} (`https://user:pass@host/…`), so we splice them in.
 */
#[AsCommand(name: 'brevo', description: "Register this tenant's Brevo webhooks (engagement and/or inbound).")]
final class BrevoWebhookCommand extends Command
{
    private const ENGAGEMENT = 'engagement';
    private const INBOUND = 'inbound';

    /**
     * Brevo transactional events we subscribe to, for delivery + open/click tracking.
     *
     * These are Brevo's *registration* event names (camelCase). They differ from the
     * *payload* event names Brevo POSTs back (snake_case: `hard_bounce`, `unique_opened`,
     * `proxy_open`, …), which Symfony's BrevoPayloadConverter decodes. There is no
     * registration event for proxy opens (Apple Mail Privacy Protection): those arrive as
     * `proxy_open`/`unique_proxy_open` payloads under the `uniqueOpened` subscription — so
     * don't add a `proxy_open` here (Brevo rejects it as an invalid transactional event).
     */
    private const ENGAGEMENT_EVENTS = ['delivered', 'hardBounce', 'softBounce', 'blocked', 'spam', 'invalid', 'deferred', 'click', 'uniqueOpened', 'unsubscribed'];

    public function __construct(
        private readonly HttpClientInterface $brevoClient,
        private readonly UrlGeneratorInterface $urlGenerator,
        #[Autowire(param: 'ferienpass.inbound_domain')]
        private readonly ?string $inboundDomain = null,
        #[Autowire(param: 'ferienpass.inbound_secret')]
        private readonly ?string $inboundSecret = null,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption('webhook', null, InputOption::VALUE_REQUIRED, 'Which webhook to register: "engagement", "inbound" or "all". Prompted if omitted.')
            ->addOption('auth', null, InputOption::VALUE_REQUIRED, 'HTTP basic auth "user:pass" to embed in the webhook URL (for a protected endpoint).')
            ->addOption('force', null, InputOption::VALUE_NONE, 'Replace an existing webhook that already points at our URL.')
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be sent to Brevo without creating anything.')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $choice = $input->getOption('webhook')
            ?? $io->choice('Which Brevo webhook do you want to register?', [self::ENGAGEMENT, self::INBOUND, 'all'], 'all');

        if (!\in_array($choice, [self::ENGAGEMENT, self::INBOUND, 'all'], true)) {
            $io->error(\sprintf('Unknown webhook "%s" — use "engagement", "inbound" or "all".', $choice));

            return Command::INVALID;
        }

        $kinds = 'all' === $choice ? [self::ENGAGEMENT, self::INBOUND] : [$choice];
        $auth = $input->getOption('auth');
        $force = (bool) $input->getOption('force');
        $dryRun = (bool) $input->getOption('dry-run');

        $exit = Command::SUCCESS;
        foreach ($kinds as $kind) {
            $io->section(\sprintf('%s webhook', ucfirst($kind)));
            if (Command::SUCCESS !== $this->registerOne($io, $kind, $auth, $force, $dryRun)) {
                $exit = Command::FAILURE;
            }
        }

        return $exit;
    }

    private function registerOne(SymfonyStyle $io, string $kind, ?string $auth, bool $force, bool $dryRun): int
    {
        $descriptor = $this->descriptorFor($kind, $auth);
        if (null === $descriptor) {
            $io->warning('Skipped: set ferienpass.inbound_domain and ferienpass.inbound_secret to enable the inbound webhook.');

            return Command::SUCCESS;
        }

        [$type, $url, $payload] = $descriptor;
        $shown = self::maskPassword($url);

        if (!str_starts_with($url, 'https://') || str_contains($url, 'localhost') || str_contains($url, '127.0.0.1')) {
            $io->warning(\sprintf('"%s" is not a public HTTPS URL — Brevo must be able to reach it. Set APP_BASE_URL.', $shown));
        }

        $rows = [['Brevo type' => $type], ['Webhook URL' => $shown]];
        if (isset($payload['domain'])) {
            $rows[] = ['Receiving domain' => $payload['domain']];
        }
        $io->definitionList(...$rows);

        if ($dryRun) {
            $io->writeln((string) json_encode([...$payload, 'url' => $shown], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES));
            $io->note('Dry run — nothing sent to Brevo (basic-auth password masked above; the real one is sent).');

            return Command::SUCCESS;
        }

        try {
            $existing = $this->findExisting($type, $url);

            if (null !== $existing && !$force) {
                $io->success(\sprintf('Already registered (id %s) — nothing to do. Use --force to re-create.', (string) ($existing['id'] ?? '?')));

                return Command::SUCCESS;
            }

            if (null !== $existing) {
                $delete = $this->brevoClient->request('DELETE', '/v3/webhooks/'.($existing['id'] ?? ''));
                if ($delete->getStatusCode() >= 400) {
                    $io->error(\sprintf('Could not delete the existing webhook (HTTP %d): %s', $delete->getStatusCode(), $delete->getContent(false)));

                    return Command::FAILURE;
                }
                $io->note(\sprintf('Removed the previous webhook (id %s).', (string) ($existing['id'] ?? '?')));
            }

            $response = $this->brevoClient->request('POST', '/v3/webhooks', ['json' => $payload]);
            if ($response->getStatusCode() >= 400) {
                $io->error(\sprintf('Brevo rejected the request (HTTP %d): %s', $response->getStatusCode(), $response->getContent(false)));

                return Command::FAILURE;
            }

            $data = $response->toArray(false);
        } catch (HttpClientException $e) {
            $io->error('Could not reach the Brevo API: '.$e->getMessage());

            return Command::FAILURE;
        }

        $io->success(\sprintf('Registered (id %s).', (string) ($data['id'] ?? '?')));

        return Command::SUCCESS;
    }

    /**
     * @return array{0: string, 1: string, 2: array<string, mixed>}|null [brevoType, url, payload], or null when not configurable
     */
    private function descriptorFor(string $kind, ?string $auth): ?array
    {
        if (self::INBOUND === $kind) {
            if (null === $this->inboundDomain || '' === $this->inboundDomain
                || null === $this->inboundSecret || '' === $this->inboundSecret
            ) {
                return null;
            }

            $url = self::withBasicAuth($this->urlGenerator->generate('_webhook_controller', ['type' => 'brevo_inbound', 'secret' => $this->inboundSecret], UrlGeneratorInterface::ABSOLUTE_URL), $auth);

            return ['inbound', $url, [
                'type' => 'inbound',
                'events' => ['inboundEmailProcessed'],
                'url' => $url,
                'domain' => $this->inboundDomain,
                'description' => 'Ferienpass inbound parse (e-mail replies → comments)',
            ]];
        }

        $url = self::withBasicAuth($this->urlGenerator->generate('_webhook_controller', ['type' => 'mailer_brevo'], UrlGeneratorInterface::ABSOLUTE_URL), $auth);

        return ['transactional', $url, [
            'type' => 'transactional',
            'events' => self::ENGAGEMENT_EVENTS,
            'url' => $url,
            'description' => 'Ferienpass engagement (delivery + open tracking)',
        ]];
    }

    /**
     * @return array<string, mixed>|null the existing webhook of $type whose URL matches (ignoring userinfo, which Brevo may hide), or null
     */
    private function findExisting(string $type, string $url): ?array
    {
        $response = $this->brevoClient->request('GET', '/v3/webhooks', ['query' => ['type' => $type]]);
        if (200 !== $response->getStatusCode()) {
            return null;
        }

        $needle = self::stripUserinfo($url);
        foreach ($response->toArray(false)['webhooks'] ?? [] as $webhook) {
            if (\is_array($webhook) && self::stripUserinfo((string) ($webhook['url'] ?? '')) === $needle) {
                return $webhook;
            }
        }

        return null;
    }

    /**
     * Splice `user:pass@` into the URL's authority so Brevo authenticates to a
     * basic-auth-protected endpoint.
     */
    private static function withBasicAuth(string $url, ?string $auth): string
    {
        if (null === $auth || '' === $auth) {
            return $url;
        }

        [$user, $pass] = array_pad(explode(':', $auth, 2), 2, '');
        $userinfo = rawurlencode($user).('' !== $pass ? ':'.rawurlencode($pass) : '');

        foreach (['https://', 'http://'] as $scheme) {
            if (str_starts_with($url, $scheme)) {
                return $scheme.$userinfo.'@'.substr($url, \strlen($scheme));
            }
        }

        return $url;
    }

    private static function maskPassword(string $url): string
    {
        return preg_replace('#(://[^:/@]+):[^@]+@#', '$1:***@', $url) ?? $url;
    }

    private static function stripUserinfo(string $url): string
    {
        return preg_replace('#(://)[^/@]+@#', '$1', $url) ?? $url;
    }
}
