<?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\ORM\Mapping as ORM;
use Ferienpass\CoreBundle\Repository\TenantDomainRepository;
use Ferienpass\CoreBundle\Tenant\Surface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * One request host that routes to a tenant, and what it serves there.
 *
 * The host is the only thing an incoming request carries, so this is the
 * tenant boundary for HTTP: the unique index makes it impossible for two
 * tenants to claim the same host, which would otherwise be a way to serve one
 * municipality's data under another's domain.
 *
 * The {@see Surface} decides which route collection answers on it:
 *
 * - `Frontend` — the public site. It also serves the admin under `/admin`,
 *   but only for as long as the tenant names no admin domain of its own.
 * - `Admin` — the admin area at the root of the domain, no `/admin` segment,
 *   and no public site. A municipality that only administers (no CMS) has
 *   nothing but domains of this kind.
 */
#[ORM\Entity(repositoryClass: TenantDomainRepository::class)]
#[ORM\UniqueConstraint(name: 'tenant_domain_host', columns: ['host'])]
#[UniqueEntity('host', message: 'tenant.domain.taken')]
class TenantDomain implements \Stringable
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Tenant::class, inversedBy: 'domains')]
    #[ORM\JoinColumn(name: 'tenant_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
    private ?Tenant $tenant = null;

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

    /**
     * Frontend by default: that is what every domain was before the admin
     * could have one of its own, and what the migration leaves existing rows
     * as.
     */
    #[ORM\Column(type: 'string', length: 16, enumType: Surface::class, options: ['default' => Surface::Frontend->value])]
    private Surface $surface = Surface::Frontend;

    public function __construct(?Tenant $tenant = null, ?string $host = null, Surface $surface = Surface::Frontend)
    {
        $this->tenant = $tenant;
        $this->surface = $surface;

        if (null !== $host) {
            $this->setHost($host);
        }
    }

    public function __toString(): string
    {
        return (string) $this->host;
    }

    /**
     * Reduces whatever was typed into the admin form (or arrives in a `Host`
     * header) to the bare, comparable hostname: lower-cased, no scheme, no
     * port, no path, no trailing dot.
     */
    public static function normalizeHost(string $host): string
    {
        $host = trim($host);

        if (str_contains($host, '//')) {
            $host = (string) parse_url($host, \PHP_URL_HOST);
        }

        $host = explode('/', $host)[0];
        // Strip the port, but leave an IPv6 literal ("[::1]") alone.
        if (!str_starts_with($host, '[')) {
            $host = explode(':', $host)[0];
        }

        return rtrim(strtolower($host), '.');
    }

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

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

    public function setTenant(?Tenant $tenant): void
    {
        $this->tenant = $tenant;
    }

    public function getHost(): ?string
    {
        return $this->host;
    }

    public function setHost(?string $host): void
    {
        $this->host = null === $host ? null : self::normalizeHost($host);
    }

    public function getSurface(): Surface
    {
        return $this->surface;
    }

    /**
     * Null falls back to the default: the admin form submits a just-added,
     * still-empty domain row without a surface during a live re-render, and
     * that must not fatal the request.
     */
    public function setSurface(?Surface $surface): void
    {
        $this->surface = $surface ?? Surface::Frontend;
    }

    public function isAdmin(): bool
    {
        return Surface::Admin === $this->surface;
    }

    public function isFrontend(): bool
    {
        return Surface::Frontend === $this->surface;
    }
}
