<?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\AdminBundle\Components;

use Contao\StringUtil;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Ferienpass\CoreBundle\Comment\CommentNotifier;
use Ferienpass\CoreBundle\Entity\ConversationItem;
use Ferienpass\CoreBundle\Entity\MessengerLog;
use Ferienpass\CoreBundle\Entity\SentEmail;
use Ferienpass\CoreBundle\Entity\SentMessage;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Message\ResendMail;
use Ferienpass\CoreBundle\Message\SendMailing;
use Ferienpass\CoreBundle\Repository\ConversationItemRepository;
use Ferienpass\CoreBundle\Repository\MessengerLogRepository;
use Ferienpass\CoreBundle\Repository\SentEmailRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerEngagementEvent;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent(route: 'live_component_admin')]
class Outbox extends AbstractController
{
    use ComponentToolsTrait;
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    /**
     * Type dots encode the audience (see {@see OutboxAudience}) — nothing else.
     * Rundmails are campaigns rather than a transactional type and keep the brand colour;
     * mail we cannot attribute to a message class stays neutral.
     */
    private const RUNDMAIL_COLOR = '#d9582b';
    private const UNKNOWN_COLOR = '#76726b';

    private const PAGE_SIZE = 25;
    private const MAX_CAMPAIGNS = 50;

    /** Recipients loaded per collapsed campaign row — the list stacks two avatars. */
    private const PREVIEW_RECIPIENTS = 2;

    #[LiveProp(writable: true, url: true)]
    public string $query = '';

    /** all | rundmail | transactional */
    #[LiveProp(writable: true, url: true)]
    public string $kind = 'all';

    /** '' | gesendet | zugestellt | geoeffnet | geklickt | bounce */
    #[LiveProp(writable: true, url: true)]
    public string $status = '';

    /** '' | today | 7d | 30d */
    #[LiveProp(writable: true, url: true)]
    public string $time = '';

    /** A transactional message class (filters the list to one notification type) */
    #[LiveProp(writable: true, url: true)]
    public string $type = '';

    /** new | old | recip */
    #[LiveProp(writable: true, url: true)]
    public string $sort = 'new';

    #[LiveProp(hydrateWith: 'hydrateGroup', dehydrateWith: 'dehydrateGroup', url: true)]
    public ?MessengerLog $group = null;

    #[LiveProp(url: true)]
    public ?SentMessage $message = null;

    /** Ids of campaign rows expanded inline to reveal their recipients. */
    #[LiveProp(writable: true)]
    public array $expanded = [];

    #[LiveProp(writable: true)]
    public int $page = 1;

    /** The outbox reply composer's draft, bound to the textarea. */
    #[LiveProp(writable: true)]
    public string $replyBody = '';

    /** Transient: set right after a successful resend to show an inline confirmation. */
    public bool $resent = false;

    private bool $hasMore = false;

    /** @var OutboxItem[]|null memoised for a single render */
    private ?array $builtItems = null;

    /** @var SentEmail[]|null memoised recipients of the selected campaign */
    private ?array $selectedRecipients = null;

    /** @var array<array{messageClass:string, count:int}>|null memoised for a single render */
    private ?array $builtTypeChips = null;

    /** @var array<int, ConversationItem[]> memoised threads, keyed by message id */
    private array $threads = [];

    /** @var array{total:int, delivered:int, opened:int, clicked:int, bounced:int, spark:int[]}|null */
    private ?array $builtStats = null;

    public function __construct(private readonly SentEmailRepository $repository, private readonly MessengerLogRepository $logRepository, private readonly ConversationItemRepository $conversations, private readonly TranslatorInterface $translator, private readonly CacheInterface $cache)
    {
    }

    // ───────────────────────── list ─────────────────────────

    /**
     * @return OutboxItem[]
     */
    #[ExposeInTemplate]
    public function items(): array
    {
        if (null !== $this->builtItems) {
            return $this->builtItems;
        }

        // campaignItems() must precede transactionalItems(): the latter sets $hasMore.
        $items = [...$this->campaignItems(), ...$this->transactionalItems()];

        if ('' !== $this->status) {
            $items = array_filter($items, fn (OutboxItem $i) => $i->hasStatus($this->status));
        }

        usort($items, $this->comparator());

        return $this->builtItems = array_values($items);
    }

    #[ExposeInTemplate]
    public function hasNextPage(): bool
    {
        $this->items();

        return $this->hasMore;
    }

    // ───────────────────────── detail ─────────────────────────

    /**
     * The selected campaign rendered as an item (for the group overview: funnel + recipient list).
     */
    #[ExposeInTemplate]
    public function selectedCampaign(): ?OutboxItem
    {
        $recipients = $this->selectedGroupRecipients();

        return [] === $recipients ? null : OutboxItem::campaign($this->group, $recipients);
    }

    /**
     * Position of the currently selected message within its campaign, for the back banner.
     *
     * @return array{index:int, total:int}|null
     */
    #[ExposeInTemplate]
    public function recipientPosition(): ?array
    {
        if (!$this->message instanceof SentMessage) {
            return null;
        }

        $recipients = array_values($this->selectedGroupRecipients());
        foreach ($recipients as $i => $recipient) {
            if ($recipient->getId() === $this->message->getId()) {
                return ['index' => $i + 1, 'total' => \count($recipients)];
            }
        }

        return null;
    }

    /**
     * Recent activity across all recipients of the selected campaign (newest first).
     *
     * @return array<array{event:string, timestamp:\DateTimeInterface, name:string, reason:?string, link:?string}>
     */
    #[ExposeInTemplate]
    public function campaignActivity(): array
    {
        $feed = [];
        foreach ($this->selectedGroupRecipients() as $recipient) {
            $name = $this->recipientName($recipient);
            foreach ($recipient->getEngagements() as $engagement) {
                $feed[] = [
                    'event' => $engagement->getEvent(),
                    'timestamp' => $engagement->getTimestamp(),
                    'name' => $name,
                    'reason' => $engagement->getReason(),
                    'link' => $engagement->getLink(),
                ];
            }
        }

        usort($feed, static fn (array $a, array $b) => $b['timestamp'] <=> $a['timestamp']);

        return \array_slice($feed, 0, 8);
    }

    /**
     * The delivery timeline of a single message (newest first), including the synthetic "sent" event.
     *
     * @return array<array{event:string, timestamp:\DateTimeInterface, reason:?string, link:?string}>
     */
    public function messageTimeline(SentMessage $message): array
    {
        $events = [['event' => 'sent', 'timestamp' => $message->getCreatedAt(), 'reason' => null, 'link' => null]];
        foreach ($message->getEngagements() as $engagement) {
            $events[] = [
                'event' => $engagement->getEvent(),
                'timestamp' => $engagement->getTimestamp(),
                'reason' => $engagement->getReason(),
                'link' => $engagement->getLink(),
            ];
        }

        usort($events, static fn (array $a, array $b) => $b['timestamp'] <=> $a['timestamp']);

        return $events;
    }

    // ───────────────────────── conversation thread ─────────────────────────

    /**
     * The conversation thread a message belongs to — the outbound item plus the
     * replies to it, oldest first — or an empty array when the message opened no
     * conversation. This is what turns "a reply to a transactional mail" into a
     * visible exchange in the Postausgang.
     *
     * @return array<int, ConversationItem>
     */
    public function thread(SentMessage $message): array
    {
        // Memoised: the list asks each row twice (hasReply, then needsReply) and the
        // walk follows lazy parent/reply associations.
        $key = $message->getId() ?? 0;
        if (isset($this->threads[$key])) {
            return $this->threads[$key];
        }

        $conversation = $message->getConversation();

        return $this->threads[$key] = $conversation instanceof ConversationItem ? $this->conversations->findThread($conversation) : [];
    }

    /**
     * Whether the recipient has replied at least once (an inbound, email-channel
     * item exists in the thread) — drives the list's reply badge.
     */
    public function hasReply(SentMessage $message): bool
    {
        foreach ($this->thread($message) as $item) {
            if ($item->isFromEmail()) {
                return true;
            }
        }

        return false;
    }

    /**
     * Whether the newest item in the thread is an inbound reply, i.e. the ball is
     * in our court — drives the amber "Antwort offen" state.
     */
    public function needsReply(SentMessage $message): bool
    {
        $thread = $this->thread($message);
        $last = [] === $thread ? null : end($thread);

        return $last instanceof ConversationItem && $last->isFromEmail();
    }

    /**
     * Recipients of the selected campaign who replied, for the group "Antworten"
     * section (each row drills into that recipient's thread).
     *
     * @return SentMessage[]
     */
    #[ExposeInTemplate]
    public function campaignRepliers(): array
    {
        return array_values(array_filter($this->selectedGroupRecipients(), fn (SentMessage $m): bool => $this->hasReply($m)));
    }

    // ───────────────────────── stats (30 days) ─────────────────────────

    /**
     * @return array{total:int, delivered:int, opened:int, clicked:int, bounced:int, spark:int[]}
     */
    #[ExposeInTemplate]
    public function stats(): array
    {
        // The header rollup ignores every filter, so it is identical for all six
        // queries a keystroke would otherwise re-run it with. Cache keys are already
        // namespaced per tenant (framework.cache.prefix_seed), and a 30-day rollup
        // tolerates minutes of staleness.
        return $this->builtStats ??= $this->cache->get('outbox.stats.30d', function (ItemInterface $item): array {
            $item->expiresAfter(300);

            return $this->computeStats();
        });
    }

    // ───────────────────────── segmented counts & type chips ─────────────────────────

    /**
     * @return array{all:int, rundmail:int, transactional:int}
     */
    #[ExposeInTemplate]
    public function segCounts(): array
    {
        $transactional = (int) $this->baseQuery()
            ->select('COUNT(email.id)')
            ->andWhere('log.id IS NULL OR log.group IS NULL')
            ->getQuery()
            ->getSingleScalarResult()
        ;

        $rundmail = (int) $this->baseQuery()
            ->select('COUNT(DISTINCT log.group)')
            ->andWhere('log.group IS NOT NULL')
            ->andWhere('log.message = :mailing')->setParameter('mailing', SendMailing::class)
            ->getQuery()
            ->getSingleScalarResult()
        ;

        return ['all' => $transactional + $rundmail, 'rundmail' => $rundmail, 'transactional' => $transactional];
    }

    /**
     * Distinct transactional notification types present in the (search/time-filtered) outbox.
     *
     * @return array<array{messageClass:string, count:int}>
     */
    #[ExposeInTemplate]
    public function typeChips(): array
    {
        if ('transactional' !== $this->kind) {
            return [];
        }

        if (null !== $this->builtTypeChips) {
            return $this->builtTypeChips;
        }

        $rows = $this->baseQuery()
            ->select('log.message AS messageClass', 'COUNT(email.id) AS cnt')
            ->andWhere('log.id IS NOT NULL')
            ->andWhere('log.group IS NULL')
            ->andWhere('log.message != :mailing')->setParameter('mailing', SendMailing::class)
            ->groupBy('messageClass')
            ->orderBy('cnt', 'DESC')
            ->getQuery()
            ->getArrayResult()
        ;

        return $this->builtTypeChips = array_map(static fn (array $row) => [
            'messageClass' => (string) $row['messageClass'],
            'count' => (int) $row['cnt'],
        ], $rows);
    }

    // ───────────────────────── presentation helpers (used from Twig) ─────────────────────────

    /**
     * Human label for a notification type. Falls back to a humanised class name when the
     * message class is not present in the translation map (e.g. a newly added notification).
     */
    public function typeLabel(string $messageClass): string
    {
        if (SendMailing::class === $messageClass) {
            return $this->translator->trans('outbox.rundmail', [], 'admin');
        }

        if ('' === $messageClass) {
            return $this->translator->trans('outbox.email', [], 'admin');
        }

        $key = 'outbox.message.'.$messageClass;
        $label = $this->translator->trans($key, [], 'admin');
        if ($label !== $key) {
            return $label;
        }

        $parts = explode('\\', $messageClass);

        return trim(preg_replace('/(?<!^)([A-Z])/', ' $1', end($parts)));
    }

    public function typeColor(string $messageClass): string
    {
        if (SendMailing::class === $messageClass) {
            return self::RUNDMAIL_COLOR;
        }

        return OutboxAudience::of($messageClass)?->color() ?? self::UNKNOWN_COLOR;
    }

    /**
     * Legend for the type chips: the audiences actually present in the current
     * chip set, so the dots are readable without hovering.
     *
     * @return array<array{value:string, color:string}>
     */
    #[ExposeInTemplate]
    public function chipAudiences(): array
    {
        $classes = array_column($this->typeChips(), 'messageClass');

        return array_map(
            static fn (OutboxAudience $a) => ['value' => $a->value, 'color' => $a->color()],
            OutboxAudience::among($classes),
        );
    }

    public function initials(?string $name): string
    {
        $name = trim((string) $name);
        if ('' === $name) {
            return '–';
        }

        // Use the local part for bare email addresses.
        if (str_contains($name, '@')) {
            $name = strstr($name, '@', true) ?: $name;
            $name = str_replace(['.', '_', '-'], ' ', $name);
        }

        $parts = preg_split('/\s+/', preg_replace('/e\.V\.|St\.|GmbH/', '', $name)) ?: [];
        $parts = array_values(array_filter($parts));

        if (1 === \count($parts)) {
            return mb_strtoupper(mb_substr($parts[0], 0, 2));
        }

        return mb_strtoupper(mb_substr($parts[0], 0, 1).mb_substr($parts[\count($parts) - 1], 0, 1));
    }

    /**
     * Human name for a recipient: the linked user's name if known, otherwise the email address.
     *
     * The name is read off the message itself, denormalised there when the mail was
     * sent. Resolving it through log→users instead costs a query per rendered row,
     * which the list pays once for every recipient it shows.
     */
    public function recipientName(SentMessage|OutboxRecipient $message): string
    {
        return match (true) {
            $message instanceof OutboxRecipient => $message->getName() ?? (string) $message->getTo(),
            $message instanceof SentEmail => $message->getToName() ?? (string) $message->getTo(),
            default => '',
        };
    }

    // ───────────────────────── actions ─────────────────────────

    #[LiveAction]
    public function setKind(#[LiveArg] string $kind): void
    {
        $this->kind = $kind;
        $this->type = '';
        $this->page = 1;
    }

    #[LiveAction]
    public function selectType(#[LiveArg] string $type): void
    {
        $this->type = $this->type === $type ? '' : $type;
        $this->page = 1;
    }

    #[LiveAction]
    public function selectMessage(#[LiveArg] SentMessage $message): void
    {
        $this->message = $message;
        $this->group = $message->getLogEntry()?->getGroup();
    }

    #[LiveAction]
    public function selectGroup(#[LiveArg] MessengerLog $message): void
    {
        $this->group = $this->group === $message ? null : $message;
        $this->message = null;

        if ($this->group instanceof MessengerLog && !\in_array($this->group->getId(), $this->expanded, true)) {
            $this->expanded[] = $this->group->getId();
        }
    }

    #[LiveAction]
    public function toggleExpand(#[LiveArg] int $id): void
    {
        $key = array_search($id, $this->expanded, true);
        if (false !== $key) {
            unset($this->expanded[$key]);
        } else {
            $this->expanded[] = $id;
        }

        $this->expanded = array_values($this->expanded);
    }

    #[LiveAction]
    public function back(): void
    {
        $this->message = null;
    }

    #[LiveAction]
    public function resend(MessageBusInterface $bus): void
    {
        if (!$this->message instanceof SentEmail || null === $this->message->getId()) {
            return;
        }

        $bus->dispatch(new ResendMail($this->message->getId()));

        $this->resent = true;
    }

    /**
     * Post the composer's draft as a reply into the selected message's
     * conversation. It flows through the same comment pipeline as the rest of
     * the app: an in-app note for colleagues and — because we address the
     * thread's external audience — an email to the recipient carrying a
     * tokenized reply-to, so they can answer back into the thread.
     */
    #[LiveAction]
    public function sendReply(EntityManagerInterface $em, CommentNotifier $notifier): void
    {
        $body = trim($this->replyBody);
        if ('' === $body || !$this->message instanceof SentMessage) {
            return;
        }

        $conversation = $this->message->getConversation();
        if (!$conversation instanceof ConversationItem) {
            return;
        }

        $user = $this->getUser();
        if (!$user instanceof User) {
            return;
        }

        // Anchor the reply at the thread root and re-address the external parties
        // the conversation already reached (host/account), leaving colleague
        // fan-out to the notifier.
        $root = $this->conversations->findThread($conversation)[0] ?? $conversation;
        $notify = array_values(array_filter(
            $root->getNotify(),
            static fn (array $target): bool => \in_array($target['g'] ?? null, ['host', 'account'], true),
        ));

        $reply = new ConversationItem($root->getSubject(), $user, $body, parent: $root, notify: $notify);

        $em->persist($reply);
        $notifier->notify($reply);

        $this->replyBody = '';
    }

    #[LiveAction]
    public function loadMore(): void
    {
        ++$this->page;
    }

    // ───────────────────────── group prop hydration ─────────────────────────

    public function dehydrateGroup(?MessengerLog $log): int|array
    {
        return $log?->getId() ?? [];
    }

    public function hydrateGroup($id): ?MessengerLog
    {
        if (\is_array($id) && 0 === \count($id)) {
            return null;
        }

        return $this->logRepository->find($id);
    }

    /**
     * @return array{total:int, delivered:int, opened:int, clicked:int, bounced:int, spark:int[]}
     */
    private function computeStats(): array
    {
        $since = new \DateTimeImmutable('-30 days');

        $total = $this->repository->countSince($since);
        $delivered = $this->repository->countWithEventSince([
            MailerDeliveryEvent::DELIVERED,
            MailerEngagementEvent::OPEN,
            MailerEngagementEvent::CLICK,
        ], $since);
        $opened = $this->repository->countWithEventSince([MailerEngagementEvent::OPEN, MailerEngagementEvent::CLICK], $since);
        $clicked = $this->repository->countWithEventSince([MailerEngagementEvent::CLICK], $since);
        $bounced = $this->repository->countWithEventSince([MailerDeliveryEvent::BOUNCE, MailerDeliveryEvent::DROPPED], $since);

        return [
            'total' => $total,
            'delivered' => $delivered,
            'opened' => $opened,
            'clicked' => $clicked,
            'bounced' => $bounced,
            'spark' => $this->sparkline($since),
        ];
    }

    /**
     * @return OutboxItem[]
     */
    private function transactionalItems(): array
    {
        if ('rundmail' === $this->kind) {
            return [];
        }

        $limit = self::PAGE_SIZE * $this->page;

        $qb = $this->repository->createQueryBuilder('email')
            ->leftJoin('email.logEntry', 'log')->addSelect('log')
            ->leftJoin('email.conversation', 'conversation')->addSelect('conversation')
            ->andWhere('log.id IS NULL OR log.group IS NULL')
            ->orderBy('email.createdAt', 'DESC')
            ->setMaxResults($limit + 1)
        ;

        if ('' !== $this->type) {
            $qb->andWhere('log.message = :type')->setParameter('type', $this->type);
        }

        $this->applyTime($qb, 'email');
        $this->applySearch($qb, 'email');

        /** @var SentEmail[] $emails */
        $emails = $qb->getQuery()->getResult();

        $this->hasMore = \count($emails) > $limit;
        $emails = \array_slice($emails, 0, $limit);

        // One query for the whole page's delivery dots. Engagements cannot be
        // fetch-joined above without breaking setMaxResults, and reading them off each
        // entity walks a lazy collection per row.
        $statuses = $this->repository->deliveryStatuses(array_map(static fn (SentEmail $e) => (int) $e->getId(), $emails));

        return array_map(static fn (SentEmail $e) => OutboxItem::transactional($e, $statuses[$e->getId()] ?? null), $emails);
    }

    /**
     * @return OutboxItem[]
     */
    private function campaignItems(): array
    {
        if ('transactional' === $this->kind) {
            return [];
        }

        $qb = $this->repository->createQueryBuilder('email')
            ->select('IDENTITY(log.group) AS gid', 'MAX(email.createdAt) AS ts')
            ->innerJoin('email.logEntry', 'log')
            ->andWhere('log.group IS NOT NULL')
            ->andWhere('log.message = :mailing')->setParameter('mailing', SendMailing::class)
            ->groupBy('gid')
            ->orderBy('ts', 'DESC')
            ->setMaxResults(self::MAX_CAMPAIGNS)
        ;

        $this->applyTime($qb, 'email');
        $this->applySearch($qb, 'email');

        $groupIds = array_map(static fn (array $row) => (int) $row['gid'], $qb->getQuery()->getArrayResult());

        if ([] === $groupIds) {
            return [];
        }

        $summaries = $this->summariesByGroup($groupIds);

        // Campaigns that render their recipients individually need the real entities:
        // the ones the user expanded; all of them while a status filter is on, since
        // that asks about recipients a preview omits; and single-recipient mailings,
        // which the list draws as a flat row down to the reply badge.
        $full = '' !== $this->status
            ? $groupIds
            : array_values(array_filter($groupIds, fn (int $id) => \in_array($id, $this->expanded, true) || 1 === ($summaries[$id]['count'] ?? 0)));

        $recipientsByGroup = $this->recipientsByGroup($full)
            + $this->previewsByGroup(array_diff_key($summaries, array_flip($full)));

        $items = [];
        foreach ($this->logRepository->findBy(['id' => $groupIds]) as $group) {
            $recipients = $recipientsByGroup[$group->getId()] ?? [];
            if ([] === $recipients) {
                continue;
            }
            $items[] = OutboxItem::campaign($group, $recipients, $summaries[$group->getId()]['count'] ?? null);
        }

        return $items;
    }

    /**
     * Per campaign: how many recipients it has, and the ids of the few the collapsed
     * row actually shows.
     *
     * The count is deliberately unfiltered, so the "+7" badge states how many the
     * mailing went to rather than how many match the current search. The preview ids
     * come from MIN/MAX of the same grouping — the newest and oldest recipient — which
     * keeps this a pure aggregate: a mailing to three thousand people returns one row
     * here, not three thousand. The list only stacks two anonymous avatars, so which
     * two they are does not matter.
     *
     * @param int[] $groupIds
     *
     * @return array<int, array{count:int, ids:int[]}>
     */
    private function summariesByGroup(array $groupIds): array
    {
        $rows = $this->repository->createQueryBuilder('email')
            ->select('IDENTITY(log.group) AS gid', 'COUNT(email.id) AS cnt', 'MAX(email.id) AS newest', 'MIN(email.id) AS oldest')
            ->innerJoin('email.logEntry', 'log')
            ->andWhere('log.group IN (:gids)')->setParameter('gids', $groupIds)
            ->groupBy('gid')
            ->getQuery()
            ->getArrayResult()
        ;

        $summaries = [];
        foreach ($rows as $row) {
            $ids = array_values(array_unique([(int) $row['newest'], (int) $row['oldest']]));

            $summaries[(int) $row['gid']] = [
                'count' => (int) $row['cnt'],
                'ids' => \array_slice($ids, 0, self::PREVIEW_RECIPIENTS),
            ];
        }

        return $summaries;
    }

    /**
     * The recipients a collapsed campaign row shows, as scalars rather than entities.
     *
     * Naming every column keeps emailData out of the result: selecting entities here
     * drags each recipient's full HTML body along to render two initials.
     *
     * @param array<int, array{count:int, ids:int[]}> $summaries keyed by campaign id
     *
     * @return array<int, OutboxRecipient[]>
     */
    private function previewsByGroup(array $summaries): array
    {
        $ids = array_merge(...array_column($summaries, 'ids'));
        if ([] === $ids) {
            return [];
        }

        $rows = $this->repository->createQueryBuilder('email')
            ->select('email.id AS id', 'email.toAddress AS toAddress', 'email.toName AS toName', 'email.subject AS subject')
            ->andWhere('email.id IN (:ids)')->setParameter('ids', $ids)
            ->getQuery()
            ->getArrayResult()
        ;

        $byId = array_column($rows, null, 'id');
        $statuses = $this->repository->deliveryStatuses($ids);

        $preview = [];
        foreach ($summaries as $gid => $summary) {
            foreach ($summary['ids'] as $id) {
                if (!isset($byId[$id])) {
                    continue;
                }

                $preview[$gid][] = new OutboxRecipient(
                    $id,
                    $byId[$id]['toAddress'],
                    $byId[$id]['toName'],
                    $byId[$id]['subject'],
                    $statuses[$id] ?? 'gesendet',
                );
            }
        }

        return $preview;
    }

    /**
     * @param int[] $groupIds
     *
     * @return array<int, SentEmail[]>
     */
    private function recipientsByGroup(array $groupIds): array
    {
        if ([] === $groupIds) {
            return [];
        }

        /** @var SentEmail[] $emails */
        $emails = $this->repository->createQueryBuilder('email')
            ->addSelect('IDENTITY(log.group) AS HIDDEN gid')
            ->innerJoin('email.logEntry', 'log')->addSelect('log')
            ->leftJoin('email.conversation', 'conversation')->addSelect('conversation')
            ->leftJoin('email.engagements', 'engagement')->addSelect('engagement')
            ->andWhere('log.group IN (:gids)')->setParameter('gids', $groupIds)
            ->addOrderBy('email.createdAt', 'DESC')
            ->getQuery()
            ->getResult()
        ;

        $byGroup = [];
        foreach ($emails as $email) {
            $gid = $email->getLogEntry()?->getGroup()?->getId();
            if (null !== $gid) {
                $byGroup[$gid][] = $email;
            }
        }

        return $byGroup;
    }

    private function comparator(): callable
    {
        return match ($this->sort) {
            'old' => static fn (OutboxItem $a, OutboxItem $b) => $a->ts <=> $b->ts,
            'recip' => static fn (OutboxItem $a, OutboxItem $b) => ($b->count() <=> $a->count()) ?: ($b->ts <=> $a->ts),
            default => static fn (OutboxItem $a, OutboxItem $b) => $b->ts <=> $a->ts,
        };
    }

    /**
     * @return SentEmail[]
     */
    private function selectedGroupRecipients(): array
    {
        if (!$this->group instanceof MessengerLog) {
            return [];
        }

        return $this->selectedRecipients ??= $this->recipientsByGroup([$this->group->getId()])[$this->group->getId()] ?? [];
    }

    /**
     * @return int[] one bucket per day across the 30-day window
     */
    private function sparkline(\DateTimeInterface $since): array
    {
        $daily = $this->repository->dailyCountsSince($since);

        $spark = [];
        $cursor = new \DateTimeImmutable('-29 days');
        $today = new \DateTimeImmutable('today');
        while ($cursor <= $today) {
            $spark[] = $daily[$cursor->format('Y-m-d')] ?? 0;
            $cursor = $cursor->modify('+1 day');
        }

        return $spark;
    }

    private function baseQuery(): QueryBuilder
    {
        $qb = $this->repository->createQueryBuilder('email')->leftJoin('email.logEntry', 'log');
        $this->applyTime($qb, 'email');
        $this->applySearch($qb, 'email');

        return $qb;
    }

    // ───────────────────────── filter helpers ─────────────────────────

    private function applyTime(QueryBuilder $qb, string $alias): void
    {
        $since = match ($this->time) {
            'today' => new \DateTimeImmutable('-1 day'),
            '7d' => new \DateTimeImmutable('-7 days'),
            '30d' => new \DateTimeImmutable('-30 days'),
            default => null,
        };

        if (null !== $since) {
            $qb->andWhere("$alias.createdAt >= :since")->setParameter('since', $since);
        }
    }

    /**
     * Search recipient address, recipient name and subject — the three columns
     * lifted out of the emailData JSON for exactly this purpose.
     *
     * This deliberately no longer matches the mail body. Searching the blob meant
     * an unindexable scan over every HTML body ever sent, on every keystroke, and
     * the body matched so much boilerplate (footers, salutations) that it made the
     * result list worse anyway. Every token must match, but each may match any of
     * the three columns, so "meier anmeldung" narrows to Meier's registration mail.
     */
    private function applySearch(QueryBuilder $qb, string $alias): void
    {
        $where = $qb->expr()->andX();
        foreach (array_filter(StringUtil::trimsplit(' ', $this->query)) as $j => $token) {
            $where->add($qb->expr()->orX(
                "$alias.toAddress LIKE :query_$j",
                "$alias.toName LIKE :query_$j",
                "$alias.subject LIKE :query_$j",
            ));
            $qb->setParameter("query_$j", "%{$token}%");
        }

        if ($where->count()) {
            $qb->andWhere($where);
        }
    }
}
