<?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 Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Comment\CommentAudienceResolver;
use Ferienpass\CoreBundle\Comment\CommentNotifier;
use Ferienpass\CoreBundle\Entity\ActivityNotification;
use Ferienpass\CoreBundle\Entity\ConversationItem;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Repository\ActivityNotificationRepository;
use Ferienpass\CoreBundle\Repository\ConversationItemRepository;
use Ferienpass\CoreBundle\Repository\SentEmailRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints\NotBlank;
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;

abstract class AbstractItemActivity extends AbstractController
{
    use ComponentToolsTrait;
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp]
    public bool $commentsOnly = false;

    #[LiveProp(writable: true)]
    #[NotBlank]
    public string $newComment = '';

    /**
     * The picked notify targets, set client-side by the mention picker and
     * decoded on submit. JSON-encoded so a plain hidden input can carry it.
     */
    #[LiveProp(writable: true)]
    public string $notifyTargetsJson = '[]';

    /** Base58 uuid of the comment being replied to, if any. */
    #[LiveProp(writable: true)]
    public ?string $replyTo = null;

    public function __construct(
        private readonly CommentAudienceResolver $audienceResolver,
        private readonly ActivityNotificationRepository $notificationRepository,
        private readonly ConversationItemRepository $commentRepository,
        private readonly SentEmailRepository $sentEmailRepository,
    ) {
    }

    #[LiveAction]
    public function comment(EntityManagerInterface $em, CommentNotifier $notifier): void
    {
        $this->validate();

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

        $comment = new ConversationItem($this->subject(), $user, trim($this->newComment), parent: $this->resolveParent(), notify: $this->decodeTargets());

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

        $this->newComment = '';
        $this->notifyTargetsJson = '[]';
        $this->replyTo = null;
        $this->resetValidation();

        $this->emit('admin_list:changed');
    }

    #[LiveAction]
    public function markAllRead(): void
    {
        $user = $this->getUser();
        if (!$user instanceof User) {
            return;
        }

        $this->notificationRepository->markReadForItems($user, $this->commentUuids());

        $this->emit('admin_list:changed');
    }

    #[LiveAction]
    public function markRead(#[LiveArg] string $uuid): void
    {
        $user = $this->getUser();
        if (!$user instanceof User) {
            return;
        }

        try {
            $commentUuid = Uuid::fromBase58($uuid);
        } catch (\InvalidArgumentException) {
            return;
        }

        $this->notificationRepository->markReadForItems($user, [$commentUuid]);

        $this->emit('admin_list:changed');
    }

    #[LiveAction]
    public function reply(#[LiveArg] string $uuid): void
    {
        $this->replyTo = $uuid;
    }

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

    /**
     * The merged feed, newest first: system events interleaved with top-level
     * comments. Replies hang off their parent (rendered nested) and are not
     * listed here.
     *
     * @return array<int, object>
     */
    #[ExposeInTemplate]
    public function activity(): array
    {
        $rows = [...$this->transitions(), ...$this->topLevelComments()];

        usort($rows, static fn (object $a, object $b): int => $b->getCreatedAt() <=> $a->getCreatedAt());

        return $rows;
    }

    /**
     * Per-comment delivery/read status for the notify pills, keyed by base58
     * uuid: { inApp: none|sent|read, email: none|sent|delivered|opened }. The
     * admin (in-app) pill uses `inApp`; host/account (email) pills use `email`.
     *
     * @return array<string, array{inApp: string, email: string}>
     */
    #[ExposeInTemplate('delivery_status')]
    public function deliveryStatus(): array
    {
        $comments = $this->visibleComments();
        if ([] === $comments) {
            return [];
        }

        $status = [];
        $rfcToBase58 = [];
        foreach ($comments as $comment) {
            $base58 = $comment->getUuid()->toBase58();
            $status[$base58] = ['inApp' => 'none', 'email' => 'none'];
            $rfcToBase58[$comment->getUuid()->toRfc4122()] = $base58;
        }

        // In-app (admin) status: aggregated read receipts.
        $tallies = [];
        foreach ($this->notificationRepository->findForComments(array_map(static fn (ConversationItem $c): Uuid => $c->getUuid(), $comments)) as $notification) {
            $base58 = $notification->getConversationItem()->getUuid()->toBase58();
            $tallies[$base58]['total'] = ($tallies[$base58]['total'] ?? 0) + 1;
            $tallies[$base58]['read'] = ($tallies[$base58]['read'] ?? 0) + ($notification->isRead() ? 1 : 0);
        }
        foreach ($tallies as $base58 => $tally) {
            // 'notified' = the bell row exists (reached); 'read' = all recipients read it.
            $status[$base58]['inApp'] = ($tally['read'] ?? 0) >= $tally['total'] ? 'read' : 'notified';
        }

        // Email (host/account) status: only shared comments produced emails.
        $sharedUuids = [];
        foreach ($comments as $comment) {
            if (!$comment->isInternal()) {
                $sharedUuids[] = $comment->getUuid()->toRfc4122();
            }
        }
        foreach ($this->sentEmailRepository->findByConversationItems($sharedUuids) as $email) {
            $base58 = $rfcToBase58[(string) $email->getConversationItem()] ?? null;
            if (null === $base58) {
                continue;
            }

            $reached = $email->isOpened() ? 'opened' : ($email->isDelivered() ? 'delivered' : 'sent');
            $status[$base58]['email'] = self::strongerStatus($status[$base58]['email'], $reached);
        }

        return $status;
    }

    /**
     * @return array{groups: array<int, array<string, mixed>>, people: array<int, array<string, mixed>>}
     */
    #[ExposeInTemplate]
    public function audiences(): array
    {
        return $this->audienceResolver->audiences($this->subject());
    }

    /**
     * Whether the feed is rendered on an offer's detail page. There the offer is
     * the subject and every attendance row belongs to it, so the offer name is
     * redundant and omitted from the wording.
     */
    #[ExposeInTemplate('on_offer_page')]
    public function onOfferPage(): bool
    {
        return $this->subject() instanceof OfferInterface;
    }

    /**
     * Base58 uuids of comments the current colleague has not read yet.
     *
     * @return array<int, string>
     */
    #[ExposeInTemplate('unread_ids')]
    public function unreadIds(): array
    {
        $user = $this->getUser();
        if (!$user instanceof User) {
            return [];
        }

        return array_map(
            static fn (ActivityNotification $n): string => $n->getConversationItem()->getUuid()->toBase58(),
            $this->notificationRepository->findUnreadForItems($user, $this->commentUuids()),
        );
    }

    /**
     * The picked notify targets, decoded for server-side rendering of the
     * composer chips (avoids morphing externally-rendered DOM).
     *
     * @return array<int, array<string, mixed>>
     */
    #[ExposeInTemplate('notify_targets')]
    public function notifyTargets(): array
    {
        return $this->decodeTargets();
    }

    #[ExposeInTemplate('reply_target')]
    public function replyTarget(): ?ConversationItem
    {
        if (null === $this->replyTo || '' === $this->replyTo) {
            return null;
        }

        try {
            return $this->commentRepository->find(Uuid::fromBase58($this->replyTo));
        } catch (\InvalidArgumentException) {
            return null;
        }
    }

    abstract protected function subject(): object;

    /**
     * @return array<int, object>
     */
    protected function transitions(): array
    {
        if ($this->commentsOnly) {
            return [];
        }

        return $this->logTransitions($this->subject());
    }

    /**
     * @return array<int, ConversationItem>
     */
    protected function topLevelComments(): array
    {
        return $this->topLevel($this->subject());
    }

    /**
     * @return array<int, ConversationItem>
     */
    protected function allComments(): array
    {
        return $this->subject()->getConversation()->toArray();
    }

    /**
     * @return array<int, object>
     */
    final protected function logTransitions(object $subject): array
    {
        if (!method_exists($subject, 'getActivity')) {
            return [];
        }

        return array_values(array_filter(
            $subject->getActivity()->toArray(),
            static fn (object $log): bool => method_exists($log, 'isTransition') && $log->isTransition() && !self::isRedundantWaiting($log),
        ));
    }

    /**
     * @return array<int, ConversationItem>
     */
    final protected function topLevel(object $subject): array
    {
        return array_values(array_filter(
            $subject->getConversation()->toArray(),
            static fn (ConversationItem $comment): bool => !$comment->isReply(),
        ));
    }

    /**
     * A non-"create" transition that lands back on WAITING ("nicht zugeteilt")
     * is the allocation's neutral "not assigned" state — the same information
     * the "angemeldet" create row already carries. Hiding it keeps an
     * application from being listed twice ("… angemeldet" + "… auf nicht
     * zugeteilt gesetzt").
     */
    private static function isRedundantWaiting(object $log): bool
    {
        if (!method_exists($log, 'getTransition')) {
            return false;
        }

        $transition = $log->getTransition();

        return null !== $transition
            && 'create' !== $transition->getName()
            && AttendanceStatus::WAITING === ($transition->getTos()[0] ?? null);
    }

    /**
     * @return array<int, Uuid>
     */
    private function commentUuids(): array
    {
        return array_map(static fn (ConversationItem $c): Uuid => $c->getUuid(), $this->allComments());
    }

    /**
     * The comments actually rendered in the feed (top-level + their replies).
     *
     * @return array<int, ConversationItem>
     */
    private function visibleComments(): array
    {
        $result = [];
        foreach ($this->topLevelComments() as $comment) {
            $result[] = $comment;
            foreach ($comment->getReplies() as $reply) {
                $result[] = $reply;
            }
        }

        return $result;
    }

    private static function strongerStatus(string $current, string $candidate): string
    {
        $rank = ['none' => 0, 'sent' => 1, 'delivered' => 2, 'opened' => 3];

        return ($rank[$candidate] ?? 0) > ($rank[$current] ?? 0) ? $candidate : $current;
    }

    private function resolveParent(): ?ConversationItem
    {
        return $this->replyTarget();
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function decodeTargets(): array
    {
        try {
            $decoded = json_decode($this->notifyTargetsJson, true, 8, \JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return [];
        }

        if (!\is_array($decoded)) {
            return [];
        }

        return array_values(array_filter(
            $decoded,
            static fn ($target): bool => \is_array($target) && isset($target['type'], $target['g']),
        ));
    }
}
