<?php

declare(strict_types=1);

namespace App\Module\Core\Domain\Entity;

use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
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\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use App\Shared\Domain\Attribute\Auditable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Role RBAC : groupe nomme de permissions assignable a un utilisateur.
 *
 * Un role peut etre "systeme" (cree et protege par la plateforme) ou
 * "personnalise" (cree par un administrateur). Seuls les roles personnalises
 * peuvent etre supprimes.
 */
#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['role:read']],
            security: "is_granted('core.roles.view')",
        ),
        new Get(
            normalizationContext: ['groups' => ['role:read']],
            security: "is_granted('core.roles.view')",
        ),
        new Post(
            normalizationContext: ['groups' => ['role:read']],
            denormalizationContext: ['groups' => ['role:write']],
            security: "is_granted('core.roles.manage')",
            processor: RoleProcessor::class,
        ),
        new Patch(
            normalizationContext: ['groups' => ['role:read']],
            denormalizationContext: ['groups' => ['role:write']],
            security: "is_granted('core.roles.manage')",
            processor: RoleProcessor::class,
        ),
        new Delete(
            security: "is_granted('core.roles.manage')",
            processor: RoleProcessor::class,
        ),
    ],
    normalizationContext: ['groups' => ['role:read']],
    denormalizationContext: ['groups' => ['role:write']],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[Auditable]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
class Role
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['role:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 100)]
    #[Groups(['role:read', 'role:write'])]
    #[Assert\NotBlank(message: 'Le code du rôle est obligatoire.', normalizer: 'trim')]
    #[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit être en snake_case et commencer par une lettre minuscule.')]
    #[Assert\Length(max: 100, maxMessage: 'Le code ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    private string $code;

    #[ORM\Column(length: 255)]
    #[Groups(['role:read', 'role:write'])]
    #[Assert\NotBlank(message: 'Le libellé du rôle est obligatoire.', normalizer: 'trim')]
    #[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
    private string $label;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    #[Groups(['role:read', 'role:write'])]
    private ?string $description = null;

    // Volontairement exclu du groupe `role:write` : un client ne doit jamais
    // pouvoir positionner ce flag via l'API. Seules les fixtures et migrations
    // creent les roles systeme.
    #[ORM\Column(name: 'is_system', options: ['default' => false])]
    #[Groups(['role:read'])]
    private bool $isSystem = false;

    /** @var Collection<int, Permission> */
    // Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) :
    // - Evite un lazy-load silencieux pendant un refresh de token JWT ou une
    //   serialisation hors contexte EntityManager (voir ticket #343, section
    //   11 risque #1) ou la collection serait inaccessible et provoquerait
    //   une erreur opaque.
    // - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle
    //   d'un CRM/ERP PME ou un role porte quelques dizaines de permissions.
    // - Si la volumetrie augmente significativement : revoir vers une
    //   projection cachee (ticket a ouvrir a ce moment-la).
    #[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
    #[ORM\JoinTable(name: 'role_permission')]
    #[Groups(['role:read', 'role:write'])]
    private Collection $permissions;

    public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)
    {
        $this->code        = $code;
        $this->label       = $label;
        $this->isSystem    = $isSystem;
        $this->description = $description;
        $this->permissions = new ArrayCollection();
    }

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

    public function getCode(): string
    {
        return $this->code;
    }

    public function getLabel(): string
    {
        return $this->label;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    // Le getter est annote directement car la convention Symfony PropertyInfo
    // strip le prefixe `is` et exposerait le champ sous le nom `system`. On
    // pose donc un SerializedName explicite pour garantir la sortie JSON-LD
    // sous `isSystem`, nom attendu par les clients de l'API.
    #[Groups(['role:read'])]
    #[SerializedName('isSystem')]
    public function isSystem(): bool
    {
        return $this->isSystem;
    }

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

    /**
     * Setter expose uniquement a la denormalisation API Platform pour
     * permettre au RoleProcessor de detecter une tentative de modification
     * du code (garde "code immuable"). Le code reste en pratique fige apres
     * creation : le processor refuse toute modification via 400.
     *
     * @internal Ne PAS appeler depuis le domaine, les fixtures ou les commandes.
     *           Hors contexte API Platform, cette methode modifie silencieusement
     *           le code sans aucun garde.
     */
    public function setCode(string $code): static
    {
        $this->code = $code;

        return $this;
    }

    /**
     * Met a jour le libelle affichable du role. Le code reste immuable pour
     * garantir la stabilite des references cote fixtures et migrations.
     */
    public function setLabel(string $label): static
    {
        $this->label = $label;

        return $this;
    }

    /**
     * Met a jour la description libre du role (champ documentaire).
     */
    public function setDescription(?string $description): static
    {
        $this->description = $description;

        return $this;
    }

    /**
     * Ajoute une permission au role. Idempotent : ajouter deux fois la meme
     * permission n'entraine pas de doublon dans la collection.
     */
    public function addPermission(Permission $permission): static
    {
        if (!$this->permissions->contains($permission)) {
            $this->permissions->add($permission);
        }

        return $this;
    }

    /**
     * Retire une permission du role. Idempotent : retirer une permission
     * absente est un no-op silencieux.
     */
    public function removePermission(Permission $permission): static
    {
        $this->permissions->removeElement($permission);

        return $this;
    }

    /**
     * Garde domaine : refuse la suppression d'un role marque comme systeme.
     * La traduction HTTP (403) est faite au niveau application / API Platform.
     */
    public function ensureDeletable(): void
    {
        if ($this->isSystem) {
            throw SystemRoleDeletionException::forRole($this);
        }
    }
}
