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

use Ferienpass\AdminBundle\Components\OutboxAudience;
use Ferienpass\CoreBundle\Message\AccountActivated;
use Ferienpass\CoreBundle\Message\AccountCreated;
use Ferienpass\CoreBundle\Message\CommentPosted;
use Ferienpass\CoreBundle\Message\HostCreated;
use Ferienpass\CoreBundle\Message\SendMailing;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Yaml\Yaml;

/**
 * The Postausgang's type chips are only as good as their two lookup tables. Both
 * used to drift: labels went missing (the chip then shows a humanised English
 * class name) and stale entries piled up pointing at classes that had been moved
 * or never existed. These tests derive the truth from the NotificationHandlers
 * and hold both tables against it.
 */
class OutboxAudienceTest extends TestCase
{
    private const ROOT = __DIR__.'/../../..';

    /**
     * Rundmails are campaigns, not a transactional type: they are excluded from the
     * chips and keep the brand colour, so they need a label but no audience.
     */
    private const NO_AUDIENCE = [SendMailing::class];

    public function testEveryMailedMessageHasAnAudience(): void
    {
        foreach ($this->mailedMessageClasses() as $messageClass) {
            self::assertNotNull(
                OutboxAudience::of($messageClass),
                \sprintf('%s produces a transactional e-mail but has no audience, so its chip dot would be neutral grey. Add it to OutboxAudience::MAP.', $messageClass),
            );
        }
    }

    public function testEveryMailedMessageHasAGermanLabel(): void
    {
        $labels = $this->labels();

        foreach ($this->mailedMessageClasses() as $messageClass) {
            self::assertArrayHasKey(
                $messageClass,
                $labels,
                \sprintf('%s produces a transactional e-mail but has no label, so its chip falls back to a humanised English class name. Add it under outbox.message in admin+intl-icu.de.yml.', $messageClass),
            );
        }
    }

    public function testNoAudienceEntryPointsAtAMissingClass(): void
    {
        foreach ($this->mappedAudienceClasses() as $messageClass) {
            self::assertTrue(class_exists($messageClass), \sprintf('OutboxAudience maps %s, which does not exist.', $messageClass));
        }
    }

    public function testNoLabelPointsAtAMissingClass(): void
    {
        foreach (array_keys($this->labels()) as $messageClass) {
            self::assertTrue(class_exists($messageClass), \sprintf('outbox.message labels %s, which does not exist (a moved or renamed message class).', $messageClass));
        }
    }

    public function testLabelsAreUniquePerAudience(): void
    {
        $seen = [];
        foreach ($this->labels() as $messageClass => $label) {
            if (\in_array($messageClass, self::NO_AUDIENCE, true)) {
                continue;
            }

            $key = OutboxAudience::of($messageClass)?->value.'|'.$label;
            self::assertArrayNotHasKey($key, $seen, \sprintf('%s and %s share the label "%s" for the same audience, so their chips are indistinguishable.', $seen[$key] ?? '', $messageClass, $label));

            $seen[$key] = $messageClass;
        }
    }

    public function testEveryAudienceHasItsOwnColour(): void
    {
        $colours = array_map(static fn (OutboxAudience $a) => $a->color(), OutboxAudience::cases());

        self::assertSame($colours, array_unique($colours));
    }

    public function testAmongKeepsAFixedOrderAndDropsUnknownClasses(): void
    {
        $among = OutboxAudience::among([
            CommentPosted::class,
            HostCreated::class,
            AccountCreated::class,
            AccountActivated::class,
            'Some\Unmapped\Message',
        ]);

        self::assertSame([OutboxAudience::Family, OutboxAudience::Host, OutboxAudience::Mixed], $among);
    }

    /**
     * Every message class a NotificationHandler listens to, i.e. every class that
     * can show up as a chip. Read off the handlers so a new notification cannot be
     * added without also landing in both tables.
     *
     * @return string[]
     */
    private function mailedMessageClasses(): array
    {
        $classes = [];

        foreach (glob(self::ROOT.'/*-bundle/src/NotificationHandler/*.php') ?: [] as $file) {
            preg_match('#/(\w+)-bundle/src/NotificationHandler/(\w+)\.php$#', $file, $m);
            $handler = \sprintf('Ferienpass\%sBundle\NotificationHandler\%s', ucfirst($m[1]), $m[2]);

            if (!class_exists($handler)) {
                continue;
            }

            $reflection = new \ReflectionClass($handler);
            if ([] === $reflection->getAttributes(AsMessageHandler::class) || !$reflection->hasMethod('__invoke')) {
                continue;
            }

            $type = $reflection->getMethod('__invoke')->getParameters()[0]->getType();
            if ($type instanceof \ReflectionNamedType) {
                $classes[$type->getName()] = true;
            }
        }

        self::assertNotEmpty($classes, 'No NotificationHandlers found — the glob is wrong.');

        return array_keys($classes);
    }

    /**
     * @return string[]
     */
    private function mappedAudienceClasses(): array
    {
        $map = new \ReflectionClass(OutboxAudience::class)->getConstant('MAP');

        return array_keys($map);
    }

    /**
     * @return array<string, string>
     */
    private function labels(): array
    {
        $translations = Yaml::parseFile(self::ROOT.'/admin-bundle/translations/admin+intl-icu.de.yml');

        return $translations['outbox']['message'];
    }
}
