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

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Ferienpass\CoreBundle\Repository\TenantRepository;
use Ferienpass\CoreBundle\Tenant\Surface;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * A tenant — one municipality's Ferienpass — inside a shared installation.
 *
 * Every row that belongs to a tenant carries a `tenant_id`
 * ({@see TenantAwareInterface}); the `tenant` Doctrine filter appends that
 * condition to every query so tenant data can never bleed across. This entity
 * itself is deliberately NOT tenant-aware: it is the tenant list, managed by
 * global admins ({@see \Ferienpass\CoreBundle\Security\GlobalAdmin}).
 *
 * Multi-tenancy is optional. Builds that serve exactly one municipality leave
 * `ferienpass.multi_tenancy.enabled` at false and need no Tenant row at all —
 * the filter is never enabled and `tenant_id` stays null everywhere.
 */
#[ORM\Entity(repositoryClass: TenantRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity('slug')]
class Tenant implements \Stringable
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    private ?int $id = null;

    #[ORM\Column(type: UuidType::NAME)]
    private Uuid $uuid;

    /**
     * The stable machine name (e.g. "nordhorn"). Used wherever a tenant has to
     * be named outside the database: the `--tenant` console option, log lines,
     * cache prefixes.
     */
    #[Assert\NotBlank]
    #[Assert\Regex('/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/', message: 'tenant.slug.invalid')]
    #[Assert\Length(max: 64)]
    #[ORM\Column(type: 'string', length: 64, unique: true)]
    private ?string $slug = null;

    #[Assert\NotBlank]
    #[ORM\Column(type: 'string', length: 255)]
    private ?string $name = null;

    /**
     * A tenant with no domain is unreachable — no host resolves to it, so
     * nobody can administer it either.
     *
     * @var Collection<int, TenantDomain>
     */
    #[Assert\Valid]
    #[Assert\Count(min: 1, minMessage: 'tenant.domains.required')]
    #[ORM\OneToMany(mappedBy: 'tenant', targetEntity: TenantDomain::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $domains;

    /**
     * A disabled tenant stops resolving: its hosts 404 rather than falling
     * through to another tenant.
     */
    #[ORM\Column(type: 'boolean', options: ['default' => false])]
    private bool $disabled = false;

    #[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
    private \DateTimeImmutable $modifiedAt;

    public function __construct()
    {
        $this->uuid = Uuid::v7();
        $this->createdAt = new \DateTimeImmutable();
        $this->modifiedAt = new \DateTimeImmutable();
        $this->domains = new ArrayCollection();
    }

    public function __toString(): string
    {
        return $this->name ?? $this->slug ?? '';
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUuid(): Uuid
    {
        return $this->uuid;
    }

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

    public function setSlug(?string $slug): void
    {
        $this->slug = null === $slug ? null : strtolower(trim($slug));
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(?string $name): void
    {
        $this->name = $name;
    }

    public function isDisabled(): bool
    {
        return $this->disabled;
    }

    public function setDisabled(bool $disabled = true): void
    {
        $this->disabled = $disabled;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getModifiedAt(): \DateTimeImmutable
    {
        return $this->modifiedAt;
    }

    public function setModifiedAt(\DateTimeImmutable $modifiedAt = new \DateTimeImmutable()): void
    {
        $this->modifiedAt = $modifiedAt;
    }

    #[ORM\PreUpdate]
    public function stampModifiedAt(): void
    {
        $this->modifiedAt = new \DateTimeImmutable();
    }

    /**
     * @return Collection<int, TenantDomain>
     */
    public function getDomains(): Collection
    {
        return $this->domains;
    }

    /**
     * @return list<string>
     */
    public function getHosts(): array
    {
        return array_values(array_filter($this->domains->map(static fn (TenantDomain $d) => $d->getHost())->toArray()));
    }

    /**
     * @return list<string>
     */
    public function getHostsFor(Surface $surface): array
    {
        return array_values(array_filter($this->domains
            ->filter(static fn (TenantDomain $d) => $surface === $d->getSurface())
            ->map(static fn (TenantDomain $d) => $d->getHost())
            ->toArray()));
    }

    /**
     * The host to use when there is no request to take one from — a cron
     * generating absolute links into this tenant, say. The first domain wins;
     * put the canonical one first.
     *
     * The public site is the answer when there is one, because most links
     * generated outside a request go to parents rather than to staff; an
     * admin link is moved to the admin host afterwards, when the route is
     * known ({@see \Ferienpass\CoreBundle\Routing\SurfaceAwareUrlGenerator}).
     */
    public function getPrimaryHost(): ?string
    {
        return $this->getPrimaryHostFor(Surface::Frontend) ?? $this->getPrimaryHostFor(Surface::Admin);
    }

    public function getPrimaryHostFor(Surface $surface): ?string
    {
        return $this->getHostsFor($surface)[0] ?? null;
    }

    /**
     * @return list<string>
     */
    public function getFrontendHosts(): array
    {
        return $this->getHostsFor(Surface::Frontend);
    }

    /**
     * @return list<string>
     */
    public function getAdminHosts(): array
    {
        return $this->getHostsFor(Surface::Admin);
    }

    /**
     * Whether this tenant has a public site at all. A municipality that only
     * administers — no CMS, no offer list, no accounts for parents — names
     * admin domains only, and every frontend route is then unreachable for it.
     */
    public function hasFrontend(): bool
    {
        return [] !== $this->getHostsFor(Surface::Frontend);
    }

    /**
     * Whether the admin has moved off the public site onto its own domain. It
     * is then served at the root there, and `<frontend-host>/admin` is gone.
     */
    public function hasDedicatedAdminHost(): bool
    {
        return [] !== $this->getHostsFor(Surface::Admin);
    }

    public function addDomain(TenantDomain $domain): void
    {
        if ($this->domains->contains($domain)) {
            return;
        }

        $domain->setTenant($this);
        $this->domains->add($domain);
    }

    public function removeDomain(TenantDomain $domain): void
    {
        $this->domains->removeElement($domain);
    }
}
