<?php

declare(strict_types=1);

namespace App\Module\Core\Domain\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php n'importe plus rien depuis le module Sites.
// Les type-hints utilisent SiteInterface (Shared/Contract) et le mapping ORM
// pointe vers la meme interface, resolue vers la classe concrete Site au boot
// via `doctrine.orm.resolve_target_entities` (cf. config/packages/doctrine.yaml).
// C'est le pattern officiel Doctrine pour les bounded contexts DDD.
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/me',
            provider: MeProvider::class,
            normalizationContext: ['groups' => ['me:read']],
        ),
        new Get(
            security: "is_granted('core.users.view')",
            normalizationContext: ['groups' => ['user:list']],
        ),
        new GetCollection(
            security: "is_granted('core.users.view')",
            normalizationContext: ['groups' => ['user:list']],
        ),
        new Post(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
        new Patch(security: "is_granted('core.users.manage')", processor: UserPasswordHasherProcessor::class),
        // Lecture dediee au drawer d'edition RBAC : meme URI que le PATCH pour une
        // API symetrique, groupe `user:rbac:read` qui expose sites/roles/directPermissions.
        // Garde `core.users.manage` (pas `.view`) car c'est l'endpoint de detail prevu
        // pour l'edition, pas la consultation generale (elle passe par GET /users/{id}).
        new Get(
            name: 'user_rbac_get',
            uriTemplate: '/users/{id}/rbac',
            security: "is_granted('core.users.manage')",
            normalizationContext: ['groups' => ['user:rbac:read']],
        ),
        new Patch(
            name: 'user_rbac_patch',
            uriTemplate: '/users/{id}/rbac',
            security: "is_granted('core.users.manage')",
            normalizationContext: ['groups' => ['user:rbac:read']],
            denormalizationContext: ['groups' => ['user:rbac:write']],
            processor: UserRbacProcessor::class,
        ),
        new Delete(security: "is_granted('core.users.manage')", processor: UserProcessor::class),
    ],
    denormalizationContext: ['groups' => ['user:write']],
)]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
#[Auditable]
class User implements UserInterface, PasswordAuthenticatedUserInterface, BusinessRoleAwareInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['me:read', 'user:list', 'user:rbac:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Assert\NotBlank(message: 'Le nom d\'utilisateur est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(max: 180, maxMessage: 'Le nom d\'utilisateur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    #[Groups(['me:read', 'user:list', 'user:write'])]
    private ?string $username = null;

    #[ORM\Column(name: 'is_admin', options: ['default' => false])]
    // Groupe d'ecriture uniquement sur la propriete pour la denormalisation PATCH /rbac.
    // Les groupes de lecture sont declares sur le getter isAdmin() afin d'exposer
    // la cle JSON "isAdmin" (Symfony strip le prefixe "is" sur les methodes sans SerializedName).
    #[Groups(['user:rbac:write'])]
    private bool $isAdmin = false;

    /**
     * Les roles RBAC metier rattaches a l'utilisateur.
     *
     * Le fetch EAGER est delibere : evite un lazy-load silencieux pendant
     * un refresh de token JWT ou une serialisation hors contexte EntityManager
     * (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est
     * accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente.
     *
     * @var Collection<int, Role>
     */
    #[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
    #[ORM\JoinTable(name: 'user_role')]
    #[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
    // La propriete s'appelle `rbacRoles` cote PHP pour ne pas entrer en
    // collision avec UserInterface::getRoles() (qui renvoie list<string>) ;
    // on reexpose la cle JSON sous `roles` via SerializedName pour rester
    // conforme au contrat API documente dans le ticket #344.
    #[SerializedName('roles')]
    private Collection $rbacRoles;

    /**
     * Les permissions directes accordees hors des roles.
     *
     * Meme justification EAGER que pour $rbacRoles : garantie que
     * getEffectivePermissions() fonctionne dans tous les contextes de chargement.
     *
     * @var Collection<int, Permission>
     */
    #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
    #[ORM\JoinTable(name: 'user_permission')]
    #[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
    private Collection $directPermissions;

    /**
     * Sites autorises pour l'utilisateur (ticket 2 du module Sites).
     *
     * Relation ManyToMany avec table de jointure `user_site`. Fetch LAZY :
     * le chargement est defere jusqu'a l'acces explicite a la collection.
     * MeProvider (ou un futur provider avec JOIN FETCH) est responsable de
     * precharger cette collection pour /api/me afin d'eviter N+1.
     *
     * Le groupe `user:list` a ete retire deliberement (securite : evite
     * de fuiter la liste des sites de chaque user via GET /api/users).
     *
     * @var Collection<int, SiteInterface>
     */
    #[ORM\ManyToMany(targetEntity: SiteInterface::class, inversedBy: 'users', fetch: 'LAZY')]
    #[ORM\JoinTable(name: 'user_site')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(name: 'site_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
    private Collection $sites;

    /**
     * Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
     *
     * Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
     * de site actif (par ex. apres creation avant premier login). La FK porte
     * `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
     * les users qui le pointaient — ils repassent simplement a `null`.
     *
     * Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
     * est enforce par UserRbacProcessor qui bascule automatiquement a `null`
     * si le site courant est retire des sites autorises.
     *
     * Fetch LAZY : MeProvider (ou un futur provider avec JOIN FETCH) assure
     * le prechargement pour /api/me. Le groupe `user:list` a ete retire
     * deliberement (securite : evite de fuiter le site actif via /api/users).
     */
    #[ORM\ManyToOne(targetEntity: SiteInterface::class, fetch: 'LAZY')]
    #[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    #[Groups(['me:read'])]
    private ?SiteInterface $currentSite = null;

    #[ORM\Column]
    #[AuditIgnore]
    private ?string $password = null;

    #[Groups(['user:write'])]
    #[AuditIgnore]
    private ?string $plainPassword = null;

    #[ORM\Column(type: 'datetime_immutable')]
    private ?DateTimeImmutable $createdAt = null;

    public function __construct()
    {
        $this->createdAt         = new DateTimeImmutable();
        $this->rbacRoles         = new ArrayCollection();
        $this->directPermissions = new ArrayCollection();
        $this->sites             = new ArrayCollection();
    }

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

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

    public function setUsername(string $username): static
    {
        $this->username = $username;

        return $this;
    }

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

    /**
     * Retourne les roles Symfony Security, derives de $isAdmin.
     *
     * ROLE_USER est toujours present pour que Symfony accepte l'authentification.
     * ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
     * SEUL levier technique de bypass RBAC (cf. section 11 du spec).
     *
     * Important : ne JAMAIS iterer $this->rbacRoles (la Collection de Role)
     * ici. Cette methode peut etre appelee pendant un refresh JWT, moment ou
     * la Collection peut ne pas etre hydratee. On se contente d'un calcul
     * base sur un scalaire.
     *
     * @see getRbacRoles() pour la collection RBAC metier (exposee en JSON sous la cle "roles").
     *
     * @return list<string>
     */
    public function getRoles(): array
    {
        $roles = ['ROLE_USER'];

        if ($this->isAdmin) {
            $roles[] = 'ROLE_ADMIN';
        }

        return $roles;
    }

    // Groupes de lecture + nom serialise explicite pour eviter que Symfony
    // ne strip le prefixe "is" et expose la cle "admin" au lieu de "isAdmin".
    #[Groups(['me:read', 'user:list', 'user:rbac:read'])]
    #[SerializedName('isAdmin')]
    public function isAdmin(): bool
    {
        return $this->isAdmin;
    }

    public function setIsAdmin(bool $isAdmin): static
    {
        $this->isAdmin = $isAdmin;

        return $this;
    }

    /**
     * Retourne la collection de roles RBAC rattaches a l'utilisateur.
     *
     * NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires.
     *
     * @return Collection<int, Role>
     */
    public function getRbacRoles(): Collection
    {
        return $this->rbacRoles;
    }

    public function addRbacRole(Role $role): static
    {
        if (!$this->rbacRoles->contains($role)) {
            $this->rbacRoles->add($role);
        }

        return $this;
    }

    public function removeRbacRole(Role $role): static
    {
        $this->rbacRoles->removeElement($role);

        return $this;
    }

    /**
     * @return Collection<int, Permission>
     */
    public function getDirectPermissions(): Collection
    {
        return $this->directPermissions;
    }

    public function addDirectPermission(Permission $permission): static
    {
        if (!$this->directPermissions->contains($permission)) {
            $this->directPermissions->add($permission);
        }

        return $this;
    }

    public function removeDirectPermission(Permission $permission): static
    {
        $this->directPermissions->removeElement($permission);

        return $this;
    }

    /**
     * Retourne l'union dedupliquee des codes de permissions effectives.
     *
     * Agrege les permissions venant des roles RBAC et les permissions directes.
     * Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me
     * apres l'evolution du MeProvider (aussi ticket #345).
     *
     * Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere
     * methode pour le piege de chargement au refresh JWT.
     *
     * @return list<string>
     */
    #[Groups(['me:read'])]
    public function getEffectivePermissions(): array
    {
        $codes = [];

        foreach ($this->rbacRoles as $role) {
            foreach ($role->getPermissions() as $permission) {
                $codes[$permission->getCode()] = true;
            }
        }

        foreach ($this->directPermissions as $permission) {
            $codes[$permission->getCode()] = true;
        }

        $keys = array_keys($codes);
        sort($keys);

        return $keys;
    }

    /**
     * Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC
     * rattaches porte le code donne. Permet aux modules tiers de detecter un
     * role metier (ex: `commerciale`) sans importer cette classe. Comparaison
     * stricte sur Role::code.
     */
    public function hasBusinessRole(string $roleCode): bool
    {
        foreach ($this->rbacRoles as $role) {
            if ($role->getCode() === $roleCode) {
                return true;
            }
        }

        return false;
    }

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

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

        return $this;
    }

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

    public function setCreatedAt(DateTimeImmutable $createdAt): static
    {
        $this->createdAt = $createdAt;

        return $this;
    }

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

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

        return $this;
    }

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

    /**
     * @return Collection<int, SiteInterface>
     */
    public function getSites(): Collection
    {
        return $this->sites;
    }

    /**
     * Idempotent : ajouter deux fois le meme site n'entraine pas de doublon.
     * Synchronise la collection inverse Site::$users en memoire pour eviter
     * un etat incoherent entre les deux cotes de la M2M dans une meme
     * session Doctrine (cf. ticket 2 review point #1).
     *
     * Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
     * La classe concrete injectee au runtime est resolue par Doctrine via
     * `resolve_target_entities` (cf. note architecture en tete de fichier).
     */
    public function addSite(SiteInterface $site): static
    {
        if (!$this->sites->contains($site)) {
            $this->sites->add($site);
            // @phpstan-ignore-next-line : Site concret toujours passe en pratique
            $site->addUser($this);
        }

        return $this;
    }

    /**
     * Retire un site de la collection + maintient la collection inverse en
     * memoire (cf. addSite). Attention : ne met PAS a jour `$currentSite`
     * si le site retire en etait le courant — cet invariant est enforce
     * par UserRbacProcessor (cote applicatif) ou doit etre maintenu
     * explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
     */
    public function removeSite(SiteInterface $site): static
    {
        if ($this->sites->removeElement($site)) {
            // @phpstan-ignore-next-line : Site concret toujours passe en pratique
            $site->removeUser($this);
        }

        return $this;
    }

    /**
     * Garde applicative rapide : teste la presence d'un site dans la
     * collection autorisee, via comparaison d'identite d'objet Doctrine.
     * Utilise par CurrentSiteProcessor pour valider un switch.
     */
    public function hasSite(SiteInterface $site): bool
    {
        return $this->sites->contains($site);
    }

    public function getCurrentSite(): ?SiteInterface
    {
        return $this->currentSite;
    }

    /**
     * Setter brut, sans garde. Usage interne pour les flux qui doivent
     * pouvoir positionner un site arbitraire ou null (reset de coherence
     * post-PATCH RBAC, fixtures, init). Pour le flux user-facing
     * "selectionner un site dans la liste autorisee", utiliser
     * switchCurrentSite() qui porte la garde domaine.
     */
    public function setCurrentSite(?SiteInterface $currentSite): static
    {
        $this->currentSite = $currentSite;

        return $this;
    }

    /**
     * Garde domaine du switch utilisateur : refuse un site qui n'est pas
     * dans la collection autorisee. Levee d'une exception domaine que le
     * processor HTTP traduit en 403 (pattern aligne sur Role::ensureDeletable
     * → SystemRoleDeletionException).
     *
     * @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
     */
    public function switchCurrentSite(SiteInterface $site): void
    {
        if (!$this->hasSite($site)) {
            throw SiteNotAuthorizedException::forSite($site);
        }

        $this->currentSite = $site;
    }
}
