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

use Ferienpass\CoreBundle\Entity\Tenant;
use Ferienpass\CoreBundle\Entity\TenantDomain;
use Ferienpass\CoreBundle\EventListener\TenantRequestListener;
use Ferienpass\CoreBundle\Repository\TenantDomainRepository;
use Ferienpass\CoreBundle\Tenant\HostResolver;
use Ferienpass\CoreBundle\Tenant\Surface;
use Ferienpass\CoreBundle\Tenant\SurfaceContext;
use Ferienpass\CoreBundle\Tenant\TenantContext;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;

class TenantRequestListenerTest extends TestCase
{
    public function testActivatesTheTenantServingTheHost(): void
    {
        $tenant = self::tenant(['ferienpass-nordhorn.de' => Surface::Frontend]);

        $context = $this->createMock(TenantContext::class);
        $context->method('isEnabled')->willReturn(true);
        $context->expects($this->once())->method('setTenant')->with($tenant);

        $this->listener($context, $tenant)($this->event('ferienpass-nordhorn.de'));
    }

    /**
     * Fail-closed is the whole point: an unclaimed host must never fall through
     * to some default municipality's data.
     */
    public function testRejectsAHostNoTenantClaims(): void
    {
        $context = $this->createMock(TenantContext::class);
        $context->method('isEnabled')->willReturn(true);
        $context->expects($this->never())->method('setTenant');

        $this->expectException(NotFoundHttpException::class);

        $this->listener($context, null)($this->event('unknown.example.com'));
    }

    public function testDoesNothingOnASingleTenantBuild(): void
    {
        $context = $this->createMock(TenantContext::class);
        $context->method('isEnabled')->willReturn(false);
        $context->expects($this->never())->method('setTenant');

        $domains = $this->createMock(TenantDomainRepository::class);
        $domains->expects($this->never())->method('findOneByHost');

        $event = $this->event('anything.example.com', path: '/admin/angebote');
        new TenantRequestListener($context, $this->surfaces($domains, false), new HostResolver($domains, false), new NullLogger())($event);

        self::assertSame('/admin/angebote', $event->getRequest()->getPathInfo());
    }

    public function testIgnoresSubRequests(): void
    {
        $context = $this->createMock(TenantContext::class);
        $context->method('isEnabled')->willReturn(true);
        $context->expects($this->never())->method('setTenant');

        $this->listener($context, null)($this->event('unknown.example.com', HttpKernelInterface::SUB_REQUEST));
    }

    #[DataProvider('adminDomainPaths')]
    public function testMovesTheRequestIntoTheAdminUrlSpace(string $path, string $expected): void
    {
        $tenant = self::tenant(['platform.my-ferienpass.de' => Surface::Admin]);

        $event = $this->event('platform.my-ferienpass.de', path: $path);
        $this->listener($this->enabledContext(), $tenant)($event);

        self::assertSame($expected, $event->getRequest()->getPathInfo());
    }

    public static function adminDomainPaths(): iterable
    {
        // With the slash: the admin index is routed as `/admin/`, and `/admin`
        // would earn a 301 that the way out strips back to `/` — the root of an
        // admin domain redirecting to itself forever.
        yield 'root' => ['/', '/admin/'];
        yield 'page' => ['/angebote', '/admin/angebote'];
        yield 'nested' => ['/angebote/4Z9k/bearbeiten', '/admin/angebote/4Z9k/bearbeiten'];
        yield 'locale prefix stays in front' => ['/nl/angebote', '/nl/admin/angebote'];
        // Not a locale, however much it looks like one.
        yield 'three-letter path segment' => ['/api/tokens', '/admin/api/tokens'];
    }

    public function testKeepsTheQueryStringWhenMovingIntoTheAdminUrlSpace(): void
    {
        $tenant = self::tenant(['platform.my-ferienpass.de' => Surface::Admin]);

        $event = $this->event('platform.my-ferienpass.de', path: '/angebote?page=2&qb_query=abc');
        $this->listener($this->enabledContext(), $tenant)($event);

        $request = $event->getRequest();
        self::assertSame('/admin/angebote', $request->getPathInfo());
        self::assertSame('/admin/angebote?page=2&qb_query=abc', $request->getRequestUri());
        self::assertSame('2', $request->query->get('page'));
    }

    /**
     * `/_fragment` is matched on the literal path by Symfony's FragmentListener,
     * so moving it would break every ESI sub-request on the admin domain.
     */
    public function testLeavesFrameworkPathsAlone(): void
    {
        $tenant = self::tenant(['platform.my-ferienpass.de' => Surface::Admin]);

        $event = $this->event('platform.my-ferienpass.de', path: '/_fragment?_path=x');
        $this->listener($this->enabledContext(), $tenant)($event);

        self::assertSame('/_fragment', $event->getRequest()->getPathInfo());
    }

    public function testRedirectsTheOldAdminUrlOnAnAdminDomain(): void
    {
        $tenant = self::tenant(['platform.my-ferienpass.de' => Surface::Admin]);

        $event = $this->event('platform.my-ferienpass.de', path: '/admin/angebote');
        $this->listener($this->enabledContext(), $tenant)($event);

        $response = $event->getResponse();
        self::assertInstanceOf(RedirectResponse::class, $response);
        self::assertSame(301, $response->getStatusCode());
        self::assertSame('https://platform.my-ferienpass.de/angebote', $response->getTargetUrl());
    }

    /**
     * Once the admin has a domain of its own, it is gone from the public site.
     */
    public function testAdminPathsAreGoneFromAPublicSiteThatHasAnAdminDomain(): void
    {
        $tenant = self::tenant([
            'ferienpass-nordhorn.de' => Surface::Frontend,
            'nordhorn.my-ferienpass.de' => Surface::Admin,
        ]);

        $this->expectException(NotFoundHttpException::class);

        $this->listener($this->enabledContext(), $tenant)($this->event('ferienpass-nordhorn.de', path: '/admin/angebote'));
    }

    public function testAPublicSiteWithoutAnAdminDomainKeepsServingTheAdmin(): void
    {
        $tenant = self::tenant(['ferienpass-nordhorn.de' => Surface::Frontend]);

        $event = $this->event('ferienpass-nordhorn.de', path: '/admin/angebote');
        $this->listener($this->enabledContext(), $tenant)($event);

        self::assertNull($event->getResponse());
        self::assertSame('/admin/angebote', $event->getRequest()->getPathInfo());
    }

    /**
     * The platform console names no tenant — the signed-in global admin does,
     * which only happens once the firewall has run.
     */
    public function testThePlatformHostServesTheAdminWithoutATenant(): void
    {
        $context = $this->createMock(TenantContext::class);
        $context->method('isEnabled')->willReturn(true);
        $context->expects($this->once())->method('setTenant')->with(null);

        $domains = $this->createMock(TenantDomainRepository::class);
        $domains->expects($this->never())->method('findOneByHost');

        $resolver = new HostResolver($domains, true, 'platform.my-ferienpass.de');
        $surfaces = new SurfaceContext($resolver, $context);

        $event = $this->event('platform.my-ferienpass.de', path: '/angebote');
        new TenantRequestListener($context, $surfaces, $resolver, new NullLogger())($event);

        self::assertSame('/admin/angebote', $event->getRequest()->getPathInfo());
        self::assertTrue($surfaces->current()?->isPlatform);
    }

    private function enabledContext(): TenantContext
    {
        $context = $this->createMock(TenantContext::class);
        $context->method('isEnabled')->willReturn(true);

        return $context;
    }

    private function listener(TenantContext $context, ?Tenant $resolved): TenantRequestListener
    {
        $domains = $this->createMock(TenantDomainRepository::class);
        $domains->method('findOneByHost')->willReturnCallback(static function (string $host) use ($resolved): ?TenantDomain {
            foreach ($resolved?->getDomains() ?? [] as $domain) {
                if ($domain->getHost() === $host) {
                    return $domain;
                }
            }

            return null;
        });

        $resolver = new HostResolver($domains, true);

        return new TenantRequestListener($context, new SurfaceContext($resolver, $context), $resolver, new NullLogger());
    }

    private function surfaces(TenantDomainRepository $domains, bool $enabled): SurfaceContext
    {
        return new SurfaceContext(new HostResolver($domains, $enabled), $this->createMock(TenantContext::class));
    }

    /**
     * @param array<string, Surface> $domains
     */
    private static function tenant(array $domains): Tenant
    {
        $tenant = new Tenant();
        $tenant->setSlug('nordhorn');
        $tenant->setName('Ferienpass Nordhorn');

        foreach ($domains as $host => $surface) {
            $tenant->addDomain(new TenantDomain($tenant, $host, $surface));
        }

        return $tenant;
    }

    private function event(string $host, int $requestType = HttpKernelInterface::MAIN_REQUEST, string $path = '/'): RequestEvent
    {
        return new RequestEvent(
            $this->createStub(HttpKernelInterface::class),
            Request::create('https://'.$host.$path),
            $requestType,
        );
    }
}
