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

use Ferienpass\CoreBundle\Entity\ConversationItem;
use Ferienpass\CoreBundle\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;

/**
 * Who may READ a comment.
 *
 * The admin team writes internal notes in the same threads a Veranstalter takes
 * part in — "Mutter ruft nochmal an, Familie ist bekannt" sits next to "@Volkshochschule
 * bitte die Begleitpersonen bestätigen". Showing a host the whole thread would
 * hand them the notes about the family, so a host is shown only what was
 * addressed to them, plus what they wrote themselves.
 *
 * Deliberately stricter than {@see ConversationItem::isInternal()}: that flag says
 * "someone outside the admin team was notified" and is true as soon as ANY host or
 * the account was addressed — on an offer with two Veranstalter it would show host
 * B what was written to host A, and it would show a host what was shared with the
 * parents. Neither is this class's answer.
 *
 * Admins see everything; the model has no notion of a note hidden from them.
 */
final readonly class ConversationVisibility
{
    public function __construct(private Security $security)
    {
    }

    /**
     * Filter a list down to what the current viewer may read, preserving order.
     *
     * @param array<int, ConversationItem> $comments
     *
     * @return array<int, ConversationItem>
     */
    public function filter(array $comments): array
    {
        if ($this->seesEverything()) {
            return array_values($comments);
        }

        return array_values(array_filter($comments, fn (ConversationItem $c): bool => $this->isVisible($c)));
    }

    public function isVisible(ConversationItem $comment): bool
    {
        if ($this->seesEverything()) {
            return true;
        }

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

        return $this->addressedTo($comment, $user);
    }

    /**
     * A reply belongs to the conversation it answers, so it inherits its parent's
     * audience: an answer written under a message a host may read is part of that
     * exchange, and hiding it would leave the host looking at their own question
     * with the reply to it missing.
     *
     * The consequence is worth stating: a colleague replying inside a thread a host
     * can read is writing where the host can read it. That is what the composer's
     * reply banner warns about — an internal note belongs in a new message, not in
     * that thread.
     */
    private function addressedTo(ConversationItem $comment, User $user): bool
    {
        // Bounded walk: the model nests one level, the cap only guards against a
        // malformed chain.
        for ($depth = 0; $depth < 8 && null !== $comment; ++$depth) {
            if ($this->isAuthor($comment, $user) || $this->addressesOneOfMyHosts($comment, $user)) {
                return true;
            }

            $comment = $comment->getParent();
        }

        return false;
    }

    /**
     * The admin team — including a Veranstalter who also holds an admin role, who
     * is then acting as staff.
     */
    private function seesEverything(): bool
    {
        return $this->security->isGranted('ROLE_ADMIN') || $this->security->isGranted('ROLE_PARTICIPANTS_ADMIN');
    }

    /**
     * Own messages are always readable — including a reply that arrived by e-mail
     * from an address we could not link to the account, which would otherwise
     * vanish from the thread for the very person who sent it.
     */
    private function isAuthor(ConversationItem $comment, User $user): bool
    {
        if (null !== $author = $comment->getAuthor()) {
            return $author->getId() === $user->getId();
        }

        $authorEmail = strtolower(trim((string) $comment->getAuthorEmail()));

        return '' !== $authorEmail && $authorEmail === strtolower(trim((string) $user->getEmail()));
    }

    /**
     * Whether the comment addressed a Veranstalter this user belongs to.
     *
     * A `host` group target without an id is the legacy "every host of the
     * subject" descriptor ({@see CommentAudienceResolver::hostEmails()} fans it out
     * that way), so it does address this user — they only reach the subject at all
     * as one of its hosts. An `account` target never does: what was shared with the
     * family is not the host's to read.
     */
    private function addressesOneOfMyHosts(ConversationItem $comment, User $user): bool
    {
        $myHostIds = [];
        foreach ($user->getHosts() as $host) {
            $myHostIds[] = $host->getId();
        }

        foreach ($comment->getNotify() as $target) {
            if ('host' !== ($target['g'] ?? null)) {
                continue;
            }

            $type = $target['type'] ?? null;
            $id = $target['id'] ?? null;

            if ('group' === $type && (null === $id || \in_array($id, $myHostIds, true))) {
                return true;
            }

            // A named person picked out of a host's team.
            if ('user' === $type && null !== $id && $id === $user->getId()) {
                return true;
            }
        }

        return false;
    }
}
