<?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\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Repository\UserRepository;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\HasLifecycleCallbacks]
// An e-mail address identifies an account within its tenant, not across the
// installation: the same parent may well hold an account in two neighbouring
// municipalities. On a single-tenant build every row has a null tenant, where
// MySQL treats each row as distinct — so the application-level check below,
// which explicitly matches "tenant IS NULL", is what enforces uniqueness there.
#[ORM\UniqueConstraint(name: 'user_tenant_email', columns: ['tenant_id', 'email'])]
#[UniqueEntity(fields: ['email', 'tenant'], errorPath: 'email', ignoreNull: false, groups: ['email_unique'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface, TenantAwareInterface
{
    use TenantAwareTrait;

    public const ACCOUNT_ROLES = [
        'ROLE_MEMBER',
        'ROLE_HOST',
        'ROLE_ADMIN',
    ];

    public const ADMIN_ROLES = [
        'ROLE_PARTICIPANTS_ADMIN',
        'ROLE_PAYMENTS_ADMIN',
        'ROLE_CMS_ADMIN',
    ];

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    private ?int $id = null;

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

    #[Assert\Email]
    #[Assert\NotBlank]
    #[ORM\Column(type: 'string', length: 180)]
    #[Groups(['notification', 'list:export:admin', 'list:export:default'])]
    private ?string $email = null;

    #[ORM\Column(type: 'string', nullable: true)]
    #[Groups(['notification', 'list:export:admin', 'list:export:default'])]
    #[Assert\NotBlank]
    private ?string $firstname = null;

    #[ORM\Column(type: 'string', nullable: true)]
    #[Groups(['notification', 'list:export:admin', 'list:export:default'])]
    #[Assert\NotBlank]
    private ?string $lastname = null;

    #[ORM\Column(type: 'string', length: 64, nullable: true)]
    #[Groups(['notification', 'list:export:admin', 'list:export:default'])]
    #[PhoneNumber(type: PhoneNumber::FIXED_LINE, defaultRegion: 'DE')]
    private ?string $phone = null;

    #[ORM\Column(type: 'string', length: 64, nullable: true)]
    #[Groups(['notification', 'list:export:admin', 'list:export:default'])]
    #[PhoneNumber(type: PhoneNumber::MOBILE, defaultRegion: 'DE')]
    private ?string $mobile = null;

    #[ORM\JoinTable(name: 'UserAddresses')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(name: 'address_id', referencedColumnName: 'id', unique: true)]
    #[ORM\ManyToMany(targetEntity: PostalAddress::class, cascade: ['persist'])]
    private Collection $postalAddresses;

    #[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
    #[Groups(['notification', 'list:export:admin', 'list:export:default'])]
    private \DateTimeInterface $createdAt;

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

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $lastLogin = null;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $dontDeleteBefore = null;

    #[ORM\Column(type: 'json')]
    private array $roles = [];

    #[ORM\Column(type: 'string', length: 5, options: ['default' => 'de'])]
    private string $locale = 'de';

    #[ORM\Column(type: 'simple_array', nullable: true)]
    private ?array $publicFields = [];

    #[ORM\Column(type: 'string', nullable: true)]
    private ?string $password = null;

    #[Assert\Length(max: 4096)]
    private ?string $plainPassword = null;

    #[ORM\Column(type: 'boolean')]
    private bool $disable = false;

    /**
     * When this account first became usable: the activation link was confirmed,
     * an admin cleared "deaktiviert", or — for accounts that are born enabled
     * (admin-created, host registration, accepted invitation) — the moment it
     * was persisted. Null means the account has never been activated at all.
     *
     * {@see \Ferienpass\CoreBundle\Cron\TruncateUnactivatedUsers} keys off this
     * to tell an abandoned registration apart from a real account an admin has
     * suspended. It used to infer that from an AccountActivated entry in the
     * notification log, which never exists for a manual activation and never at
     * all on instances where the "Konto aktiviert" notification is disabled —
     * so suspending such an account silently queued it for deletion.
     */
    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $activatedAt = null;

    #[ORM\Column(type: 'boolean', options: ['default' => false])]
    private bool $magicLinksDisabled = false;

    #[ORM\Column(type: 'boolean')]
    private bool $blockedEmail = false;

    #[ORM\Column(type: 'boolean')]
    private bool $unsubscribed = false;

    /**
     * Whether comment mentions and replies are forwarded to this colleague by
     * e-mail (the in-app bell fires regardless). Opt-out; on by default.
     */
    #[ORM\Column(type: 'boolean', options: ['default' => true])]
    private bool $notifyComments = true;

    #[ORM\OneToOne(mappedBy: 'user', targetEntity: BlockedContact::class, cascade: ['persist'])]
    private ?BlockedContact $blockedContact = null;

    #[ORM\OneToMany(mappedBy: 'user', targetEntity: HostMemberAssociation::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    private Collection $hostAssociations;

    #[ORM\OneToMany(mappedBy: 'user', targetEntity: ParticipantInterface::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY')]
    private Collection $participants;

    #[ORM\OneToMany(mappedBy: 'user', targetEntity: Payment::class)]
    private Collection $payments;

    #[ORM\OneToOne(mappedBy: 'user', targetEntity: Debtor::class, cascade: ['persist'], fetch: 'EAGER')]
    private ?Debtor $debtor = null;

    #[ORM\OneToMany(mappedBy: 'user', targetEntity: Consent::class)]
    private Collection $consents;

    #[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: ApiToken::class)]
    private Collection $apiTokens;

    #[ORM\OneToMany(mappedBy: 'account', targetEntity: UserActivity::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[ORM\OrderBy(['createdAt' => 'DESC'])]
    private Collection $activity;

    #[ORM\OneToMany(mappedBy: 'account', targetEntity: ConversationItem::class, fetch: 'EXTRA_LAZY')]
    #[ORM\OrderBy(['createdAt' => 'ASC'])]
    private Collection $conversation;

    /* Scopes given during API authentication */
    private ?array $accessTokenScopes = null;

    public function __construct()
    {
        $this->uuid = Uuid::v7();
        $this->createdAt = new \DateTimeImmutable();
        $this->modifiedAt = new \DateTimeImmutable();
        $this->postalAddresses = new ArrayCollection();
        $this->hostAssociations = new ArrayCollection();
        $this->participants = new ArrayCollection();
        $this->payments = new ArrayCollection();
        $this->consents = new ArrayCollection();
        $this->apiTokens = new ArrayCollection();
        $this->activity = new ArrayCollection();
        $this->conversation = new ArrayCollection();
    }

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

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

    public function getFirstname(): ?string
    {
        return $this->firstname;
    }

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

    public function getLastname(): ?string
    {
        return $this->lastname;
    }

    public function getName(): string
    {
        return \sprintf('%s %s', $this->getFirstname(), $this->getLastname());
    }

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

    public function getPhone(): ?string
    {
        return $this->phone;
    }

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

    public function getMobile(): ?string
    {
        return $this->mobile;
    }

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

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

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

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

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(?string $email): self
    {
        $this->email = $email;

        return $this;
    }

    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    public function getUsername(): string
    {
        return $this->getUserIdentifier();
    }

    public function getRoles(): array
    {
        $roles[] = $this->roles;

        if (null !== $this->accessTokenScopes) {
            $roles[] = $this->accessTokenScopes;
        }

        // guarantee every user at least has ROLE_USER
        $roles[] = ['ROLE_USER'];

        return array_unique(array_merge(...$roles));
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    public function getLocale(): string
    {
        return $this->locale;
    }

    public function setLocale(string $locale): void
    {
        $this->locale = $locale;
    }

    public function addAdminRole(string $role): void
    {
        if (!\in_array($role, self::ADMIN_ROLES, true) || \in_array($role, $this->roles, true)) {
            return;
        }

        $this->roles[] = $role;
    }

    public function removeAdminRole(string $role): void
    {
        if (!\in_array($role, self::ADMIN_ROLES, true) || !\in_array($role, $this->roles, true)) {
            return;
        }

        foreach (array_keys($this->roles, $role, true) as $key) {
            unset($this->roles[$key]);
        }

        $this->roles = array_values($this->roles);
    }

    public function getAdminRoles(): array
    {
        return array_intersect($this->roles, self::ADMIN_ROLES);
    }

    public function addAccountRole(string $role): void
    {
        if (!\in_array($role, self::ACCOUNT_ROLES, true) || \in_array($role, $this->roles, true)) {
            return;
        }

        $this->roles[] = $role;
    }

    public function removeAccountRole(string $role): void
    {
        if (!\in_array($role, self::ACCOUNT_ROLES, true) || !\in_array($role, $this->roles, true)) {
            return;
        }

        foreach (array_keys($this->roles, $role, true) as $key) {
            unset($this->roles[$key]);
        }

        $this->roles = array_values($this->roles);
    }

    public function getAccountRoles(): array
    {
        return array_intersect($this->roles, self::ACCOUNT_ROLES);
    }

    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

    /**
     * Detach the user's hashed password. Callers are responsible for ensuring
     * an alternative authentication method (a passkey, typically) is in place
     * before invoking this — the entity itself doesn't enforce a fallback.
     */
    public function clearPassword(): void
    {
        $this->password = null;
    }

    public function getPlainPassword(): ?string
    {
        return $this->plainPassword;
    }

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

    #[Groups(['list:export:admin', 'list:export:default'])]
    public function isDisabled(): bool
    {
        return $this->disable;
    }

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

        // Enabling an account is what "activated" means, no matter whether that
        // came from the e-mail link or from an admin clearing the checkbox.
        // Only the first time counts — suspending and re-enabling later must
        // not move the stamp, and re-disabling must never clear it.
        if (!$disable) {
            $this->activatedAt ??= new \DateTimeImmutable();
        }
    }

    public function getActivatedAt(): ?\DateTimeImmutable
    {
        return $this->activatedAt;
    }

    public function isActivated(): bool
    {
        return $this->activatedAt instanceof \DateTimeImmutable;
    }

    /**
     * Accounts created by an admin (invitation, host registration, the accounts
     * screen) are enabled from the start and never pass through
     * setDisabled(false) — but they are activated the moment they exist.
     * Without this they would keep a null stamp and become deletable by
     * {@see \Ferienpass\CoreBundle\Cron\TruncateUnactivatedUsers} as soon as an
     * admin suspended them.
     */
    #[ORM\PrePersist]
    public function stampActivatedAtOnCreate(): void
    {
        if (!$this->disable) {
            $this->activatedAt ??= new \DateTimeImmutable();
        }
    }

    public function isBlockedEmail(): bool
    {
        return $this->blockedEmail;
    }

    public function isMagicLinksDisabled(): bool
    {
        return $this->magicLinksDisabled;
    }

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

    public function isNotifyComments(): bool
    {
        return $this->notifyComments;
    }

    public function setNotifyComments(bool $notifyComments): void
    {
        $this->notifyComments = $notifyComments;
    }

    public function setBlockedEmail(bool $blockedEmail): void
    {
        $this->blockedEmail = $blockedEmail;
    }

    public function isUnsubscribed(): bool
    {
        return $this->unsubscribed;
    }

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

    public function getBlockedContact(): ?BlockedContact
    {
        return $this->blockedContact;
    }

    public function isSuperAdmin(): bool
    {
        return \in_array('ROLE_SUPER_ADMIN', $this->getRoles(), true);
    }

    /**
     * A global admin runs the installation, not a municipality: they administer
     * the tenant list itself and may step into any tenant to support it. The
     * role sits above ROLE_SUPER_ADMIN in the hierarchy, so a global admin has
     * every super-admin permission on top.
     *
     * The account still belongs to exactly one tenant like everyone else — it
     * signs in on that tenant's host, and switching tenants is a deliberate,
     * per-session act ({@see \Ferienpass\AdminBundle\Controller\Page\TenantsController}).
     */
    public function isGlobalAdmin(): bool
    {
        return \in_array('ROLE_GLOBAL_ADMIN', $this->getRoles(), true);
    }

    public function setGlobalAdmin(bool $globalAdmin): void
    {
        if ($globalAdmin) {
            if (!\in_array('ROLE_GLOBAL_ADMIN', $this->roles, true)) {
                $this->roles[] = 'ROLE_GLOBAL_ADMIN';
            }

            return;
        }

        if (!\in_array('ROLE_GLOBAL_ADMIN', $this->roles, true)) {
            return;
        }

        foreach (array_keys($this->roles, 'ROLE_GLOBAL_ADMIN', true) as $key) {
            unset($this->roles[$key]);
        }

        // Demoting a global admin leaves a super admin, not a nobody.
        if (!\in_array('ROLE_SUPER_ADMIN', $this->roles, true)) {
            $this->roles[] = 'ROLE_SUPER_ADMIN';
        }

        $this->roles = array_values($this->roles);
    }

    public function isAdmin(): bool
    {
        return \in_array('ROLE_ADMIN', $this->getRoles(), true);
    }

    public function isHost(): bool
    {
        return \in_array('ROLE_HOST', $this->getRoles(), true);
    }

    public function isMember(): bool
    {
        return \in_array('ROLE_MEMBER', $this->getRoles(), true);
    }

    public function setSuperAdmin(bool $superAdmin): void
    {
        if ($superAdmin) {
            if (\in_array('ROLE_SUPER_ADMIN', $this->roles, true)) {
                return;
            }

            $this->roles[] = 'ROLE_SUPER_ADMIN';

            return;
        }

        if (!\in_array('ROLE_SUPER_ADMIN', $this->roles, true)) {
            return;
        }

        foreach (array_keys($this->roles, 'ROLE_SUPER_ADMIN', true) as $key) {
            unset($this->roles[$key]);
        }

        // Make sure the ROLE_ADMIN stays
        if (!\in_array('ROLE_ADMIN', $this->roles, true)) {
            $this->roles[] = 'ROLE_ADMIN';
        }

        $this->roles = array_values($this->roles);
    }

    public function getPostalAddresses(): Collection
    {
        return $this->postalAddresses;
    }

    public function addPostalAddress(?PostalAddress $address): void
    {
        if (!$address instanceof PostalAddress) {
            return;
        }

        $this->postalAddresses->add($address);
    }

    public function removePostalAddress(PostalAddress $address): void
    {
        if (!$this->postalAddresses->contains($address)) {
            return;
        }

        $this->postalAddresses->removeElement($address);
    }

    public function getHosts(): Collection
    {
        return $this->hostAssociations->map(static fn (HostMemberAssociation $a) => $a->getHost());
    }

    public function hasHost(Host $host): bool
    {
        return !$this->hostAssociations
            ->matching(Criteria::create()->where(Criteria::expr()->eq('host', $host)))
            ->isEmpty();
    }

    public function addHost(Host $host): void
    {
        if ($this->hasHost($host)) {
            return;
        }

        $this->hostAssociations[] = new HostMemberAssociation($this, $host);
    }

    public function removeHost(Host $host): void
    {
        /** @var HostMemberAssociation $hostAssociation */
        foreach ($this->hostAssociations as $hostAssociation) {
            if ($hostAssociation->getHost() === $host) {
                $this->hostAssociations->removeElement($hostAssociation);

                return;
            }
        }
    }

    public function getLastLogin(): ?\DateTimeImmutable
    {
        return $this->lastLogin;
    }

    public function setLastLogin(?\DateTimeImmutable $lastLogin): void
    {
        $this->lastLogin = $lastLogin;
    }

    public function getPublicFields(): ?array
    {
        return $this->publicFields;
    }

    public function setPublicFields(array $publicFields): void
    {
        $this->publicFields = $publicFields;
    }

    public function getDontDeleteBefore(): ?\DateTimeImmutable
    {
        return $this->dontDeleteBefore;
    }

    public function setDontDeleteBefore(?\DateTimeImmutable $dontDeleteBefore): void
    {
        $this->dontDeleteBefore = $dontDeleteBefore;
    }

    /** @return Collection<int, BaseParticipant> */
    public function getParticipants(): Collection
    {
        return $this->participants;
    }

    public function addParticipant(ParticipantInterface $participant): void
    {
        $participant->setUser($this);

        $this->participants->add($participant);
    }

    public function removeParticipant(?ParticipantInterface $participant = null): void
    {
        if (!$this->participants->contains($participant)) {
            return;
        }

        $this->participants->removeElement($participant);
    }

    public function eraseCredentials(): void
    {
        $this->plainPassword = null;
    }

    public function getPayments(): Collection
    {
        return $this->payments;
    }

    public function getConsents(): Collection
    {
        return $this->consents;
    }

    public function getActivity(): Collection
    {
        return $this->activity;
    }

    public function getConversation(): Collection
    {
        return $this->conversation;
    }

    public function getApiTokens(): Collection
    {
        return $this->apiTokens;
    }

    public function markAsTokenAuthenticated(array $scopes): void
    {
        $this->accessTokenScopes = $scopes;
    }

    public function getDebtor(): Debtor
    {
        if (!$this->debtor instanceof Debtor) {
            $this->createDebtor();
        }

        return $this->debtor;
    }

    public function createDebtor(): void
    {
        if ($this->debtor instanceof Debtor) {
            return;
        }

        $this->debtor = new Debtor($this);
    }
}
