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

use Contao\CoreBundle\DataContainer\PaletteManipulator;
use Contao\CoreBundle\DependencyInjection\Attribute\AsCallback;
use Contao\DataContainer;
use Ferienpass\CoreBundle\Entity\Tenant;
use Ferienpass\CoreBundle\Entity\TenantDomain;
use Ferienpass\CoreBundle\Repository\TenantRepository;
use Ferienpass\CoreBundle\Tenant\TenantContext;
use Ferienpass\CoreBundle\Tenant\TenantRootPage;

/**
 * Binds a Contao root page to a tenant, and keeps that binding trustworthy.
 *
 * Contao is not multi-tenant — one page tree, one back end, one file manager —
 * so a shared installation needs a rule about where one municipality's website
 * ends and the next one's begins. The rule is the root page: **one root page,
 * one tenant**. Everything below a root belongs to the municipality named
 * there, and the code that needs "the" root page asks
 * {@see TenantRootPage} for the tenant's own instead of taking the first one it
 * finds.
 *
 * Three things are enforced when a root page is saved, all of them ways to
 * serve one municipality's site under another's data:
 *
 * - a root page must name a tenant,
 * - no two root pages may name the same tenant,
 * - the root page's domain must be one of the tenant's domains — the two host
 *   mappings (Contao's `dns`, the tenant's {@see TenantDomain} rows) decide
 *   different halves of the same request, so they may not disagree.
 *
 * All of it is inert on single-tenant builds: the field is not even shown.
 */
class TenantRootPageListener
{
    public function __construct(
        private readonly TenantContext $tenantContext,
        private readonly TenantRepository $tenants,
        private readonly TenantRootPage $rootPages,
    ) {
    }

    /**
     * Shows the field on multi-tenant builds only. The column exists either
     * way (see the DCA), but on an installation serving one municipality there
     * is nothing to choose.
     */
    #[AsCallback(table: 'tl_page', target: 'config.onload')]
    public function registerField(): void
    {
        if (!$this->tenantContext->isEnabled()) {
            return;
        }

        PaletteManipulator::create()
            ->addField('tenant', 'dns', PaletteManipulator::POSITION_BEFORE)
            ->applyToPalette('root', 'tl_page')
            ->applyToPalette('rootfallback', 'tl_page')
        ;
    }

    /**
     * @return array<int, string>
     */
    #[AsCallback(table: 'tl_page', target: 'fields.tenant.options')]
    public function tenantOptions(): array
    {
        $options = [];

        foreach ($this->tenants->findAllOrdered() as $tenant) {
            $id = $tenant->getId();
            if (null === $id) {
                continue;
            }

            $options[$id] = \sprintf('%s (%s)%s', $tenant->getName(), $tenant->getSlug(), $tenant->isDisabled() ? ' — deaktiviert' : '');
        }

        return $options;
    }

    /**
     * Runs once per save with every submitted value, so the tenant and the
     * domain can be judged together no matter which of them the editor
     * touched. Throwing here aborts the save and shows the message.
     *
     * @param array<string, mixed> $values
     *
     * @return array<string, mixed>
     */
    #[AsCallback(table: 'tl_page', target: 'config.onbeforesubmit')]
    public function validate(array $values, DataContainer $dc): array
    {
        if (!$this->tenantContext->isEnabled()) {
            return $values;
        }

        $record = array_merge($dc->getCurrentRecord() ?? [], $values);

        if ('root' !== ($record['type'] ?? '')) {
            // A page that is no longer a root page must not keep claiming a
            // tenant, or the next root page for that tenant is refused.
            if (0 !== (int) ($record['tenant'] ?? 0)) {
                $values['tenant'] = 0;
            }

            return $values;
        }

        $tenant = $this->resolveTenant((int) ($record['tenant'] ?? 0));

        $this->assertUnclaimed($tenant, (int) ($record['id'] ?? 0));
        $this->assertDomainBelongsToTenant((string) ($record['dns'] ?? ''), $tenant);

        return $values;
    }

    /**
     * Duplicating a root page copies every column, the tenant among them, and
     * never goes near the edit form — so the copy would silently be a second
     * root page for the same municipality. It starts out unassigned instead.
     */
    #[AsCallback(table: 'tl_page', target: 'config.oncopy')]
    public function clearTenantOnCopy(int|string $insertId, DataContainer $dc): void
    {
        if (!$this->tenantContext->isEnabled()) {
            return;
        }

        $this->rootPages->releaseRootPage((int) $insertId);
    }

    private function resolveTenant(int $id): Tenant
    {
        if ($id <= 0) {
            throw new \RuntimeException('Bitte ordnen Sie diesen Startpunkt einem Mandanten zu. Ohne Mandant weiß der Ferienpass nicht, wessen Angebote und Anmeldungen unter dieser Domain laufen.');
        }

        $tenant = $this->tenants->find($id);
        if (!$tenant instanceof Tenant) {
            throw new \RuntimeException(\sprintf('Den Mandanten mit der ID %s gibt es nicht (mehr).', $id));
        }

        return $tenant;
    }

    private function assertUnclaimed(Tenant $tenant, int $pageId): void
    {
        $claimed = $this->rootPages->rootPagesOfTenant((int) $tenant->getId(), $pageId);
        if ([] === $claimed) {
            return;
        }

        throw new \RuntimeException(\sprintf('Der Mandant „%s“ gehört bereits zum Startpunkt „%s“ (ID %s). Ein Mandant hat genau einen Startpunkt — alles darunter ist seine Website.', (string) $tenant->getName(), $claimed[0]['title'], $claimed[0]['id']));
    }

    private function assertDomainBelongsToTenant(string $dns, Tenant $tenant): void
    {
        $host = $this->normalizeHost($dns);

        if ('' === $host) {
            throw new \RuntimeException('Bitte tragen Sie eine Domain ein. Ohne Domain beantwortet dieser Startpunkt jeden Aufruf, auch den eines anderen Mandanten.');
        }

        // Frontend domains only: an admin domain serves the admin area at its
        // root and nothing else, so a root page pointed at one would name a
        // website that is never reached.
        $hosts = array_map($this->normalizeHost(...), $tenant->getFrontendHosts());
        if (\in_array($host, $hosts, true)) {
            return;
        }

        throw new \RuntimeException(\sprintf('Die Domain „%s“ gehört nicht zum Mandanten „%s“ (%s). Tragen Sie sie zuerst als Domain der öffentlichen Website beim Mandanten ein — sonst löst diese Domain den einen Mandanten auf und zeigt die Website des anderen.', $host, (string) $tenant->getName(), [] === $hosts ? 'keine Domains für die öffentliche Website hinterlegt' : implode(', ', $hosts)));
    }

    /**
     * Contao stores `dns` IDNA-encoded ({@see \Contao\Idna}), the tenant's
     * domains as they were typed. Comparing them raw would reject a perfectly
     * good umlaut domain.
     */
    private function normalizeHost(string $host): string
    {
        $host = TenantDomain::normalizeHost($host);
        if ('' === $host) {
            return '';
        }

        return strtolower(idn_to_ascii($host, \IDNA_NONTRANSITIONAL_TO_ASCII) ?: $host);
    }
}
