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

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Ferienpass\CoreBundle\Entity\Tenant;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RequestContext;

/**
 * Which tenant the current request, console command or worker message belongs
 * to — and the single place that turns the {@see TenantFilter} on and off.
 *
 * Nothing else may touch the filter: enabling it without a parameter, or
 * setting the parameter without enabling it, are both ways to silently serve
 * the wrong municipality's data. Go through {@see setTenant()} instead.
 *
 * On single-tenant builds (`ferienpass.multi_tenancy.enabled: false`) this is
 * inert: the tenant stays null, the filter is never enabled, and every query
 * behaves exactly as it did before multi-tenancy existed.
 */
class TenantContext
{
    private ?Tenant $tenant = null;

    private ?string $configuredHost = null;

    public function __construct(
        private readonly ManagerRegistry $doctrine,
        #[Autowire(param: 'ferienpass.multi_tenancy.enabled')]
        private readonly bool $enabled = false,
        private readonly ?RequestStack $requestStack = null,
        private readonly ?RequestContext $requestContext = null,
    ) {
    }

    public function isEnabled(): bool
    {
        return $this->enabled;
    }

    public function getTenant(): ?Tenant
    {
        return $this->tenant;
    }

    /**
     * The active tenant, for code that cannot sensibly continue without one.
     */
    public function getTenantOrFail(): Tenant
    {
        if (!$this->tenant instanceof Tenant) {
            throw new \LogicException('No tenant is active. Resolve one (by host, --tenant, or a message stamp) before touching tenant-scoped data.');
        }

        return $this->tenant;
    }

    public function getSlug(): ?string
    {
        return $this->tenant?->getSlug();
    }

    /**
     * Activates a tenant and re-applies the Doctrine filter.
     *
     * Passing null clears the tenant and disables the filter, which means
     * "every tenant at once" — legitimate for the tenant administration itself
     * and for maintenance commands, never for serving a request.
     */
    public function setTenant(?Tenant $tenant): void
    {
        $this->tenant = $tenant;

        $this->applyFilter();
        $this->applyRouterContext();
    }

    /**
     * Runs $callback with $tenant active, restoring the previous tenant
     * afterwards — the way cron jobs and workers hop from tenant to tenant.
     *
     * @template T
     *
     * @param callable(): T $callback
     *
     * @return T
     */
    public function runFor(?Tenant $tenant, callable $callback): mixed
    {
        $previous = $this->tenant;

        $this->setTenant($tenant);

        try {
            return $callback();
        } finally {
            $this->setTenant($previous);
        }
    }

    /**
     * Runs $callback with the filter off, across all tenants.
     *
     * Reserve this for global-admin surfaces and maintenance code; anything a
     * tenant's own users can reach must stay filtered.
     *
     * @template T
     *
     * @param callable(): T $callback
     *
     * @return T
     */
    public function runUnfiltered(callable $callback): mixed
    {
        return $this->runFor(null, $callback);
    }

    /**
     * Namespaces a cache key by tenant.
     *
     * The config caches (page config, integration credentials) live in one
     * shared pool. An unscoped key would hand one municipality's settings — or
     * another's API credentials — to the next request arriving from a different
     * host, which the Doctrine filter cannot prevent because no query runs.
     */
    public function cacheKey(string $key): string
    {
        $slug = $this->tenant?->getSlug();

        return null === $slug ? $key : $key.'.tenant.'.$slug;
    }

    /**
     * Re-applies the current tenant to the (possibly reset or reopened) entity
     * manager. Doctrine drops filter state when the manager is closed and
     * replaced, which is exactly what happens between two worker messages.
     */
    public function refresh(): void
    {
        $this->applyFilter();
    }

    /**
     * Points the router at the tenant's own host when there is no request to
     * take one from.
     *
     * A cron generating an absolute link has no request context, so without
     * this it falls back to `router.request_context.host` — one host for the
     * whole installation, i.e. every tenant's reminder mail linking into some
     * other municipality's site.
     *
     * During a request the context already describes the host the visitor
     * actually used, which is more specific than the tenant's canonical domain,
     * so it is left alone. In a worker the same is true of the dispatching
     * request's context, which the `router_context` middleware restores *inside*
     * the tenant middleware and therefore after this.
     *
     * Only the host is set; the scheme stays whatever `framework.router` was
     * configured with.
     */
    private function applyRouterContext(): void
    {
        if (!$this->enabled || null === $this->requestContext || null !== $this->requestStack?->getMainRequest()) {
            return;
        }

        $this->configuredHost ??= $this->requestContext->getHost();

        $this->requestContext->setHost($this->tenant?->getPrimaryHost() ?? $this->configuredHost);
    }

    private function applyFilter(): void
    {
        if (!$this->enabled) {
            return;
        }

        $manager = $this->doctrine->getManager();
        if (!$manager instanceof EntityManagerInterface) {
            return;
        }

        $filters = $manager->getFilters();

        if (!$this->tenant instanceof Tenant || null === $this->tenant->getId()) {
            if ($filters->isEnabled(TenantFilter::NAME)) {
                $filters->disable(TenantFilter::NAME);
            }

            return;
        }

        $filter = $filters->isEnabled(TenantFilter::NAME) ? $filters->getFilter(TenantFilter::NAME) : $filters->enable(TenantFilter::NAME);
        $filter->setParameter(TenantFilter::PARAMETER, $this->tenant->getId(), Types::INTEGER);
    }
}
