<?php

declare(strict_types=1);

namespace App\Module\Core\Application\Rbac;

use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\RbacSeedException;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\SiteProviderInterface;
use App\Shared\Domain\Security\BusinessRoles;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

/**
 * Source UNIQUE (anti-drift) du RBAC metier MALIO : les 4 roles
 * (bureau / compta / commerciale / usine), la matrice § 2.7 (role -> permissions)
 * et les comptes demo par role. Aucun de ces litteraux ne doit etre duplique
 * ailleurs (ni SQL en dur, ni autre fixture).
 *
 * Consomme par :
 *  - la commande applicative `app:seed-rbac` (presente dans le build prod, donc
 *    rejouable en recette/prod, contrairement aux fixtures `require-dev`) ;
 *  - la fixture Core dev/test (DRY : meme seeder).
 *
 * Toutes les operations sont idempotentes et non destructives :
 *  - ensureRoles() : cree un role par lookup de code (skip si present) ;
 *  - attachMatrix() : attache les permissions § 2.7 via la M2M role_permission,
 *    sans re-attacher un lien existant ; STOP explicite si un code manque ;
 *  - ensureDemoUsers() : cree un user par role (lookup par username, skip si
 *    present), rattache au role + a >= 1 site.
 */
final class RbacSeeder
{
    /**
     * Codes des roles metier (snake_case, regex Role respectee). `commerciale`
     * reference la constante Shared pour eviter tout drift : un seul litteral
     * pour ce code.
     */
    public const string ROLE_BUREAU      = 'bureau';
    public const string ROLE_COMPTA      = 'compta';
    public const string ROLE_COMMERCIALE = BusinessRoles::COMMERCIALE;
    public const string ROLE_USINE       = 'usine';

    /** Site de rattachement par defaut des comptes demo (cf. SitesFixtures). */
    private const string DEFAULT_SITE_NAME = 'Chatellerault';

    /**
     * Definition unique des 4 roles + matrice § 2.7. La cle est le code du role,
     * `label` le libelle FR affichable, `permissions` la liste des codes RBAC a
     * attacher (admin n'apparait pas car il bypass tout via isAdmin ;
     * `commercial.clients.archive`, `commercial.suppliers.archive`,
     * `technique.providers.archive` et `transport.carriers.archive` ne sont
     * attaches a aucun role metier — admin seul).
     *
     * Cloisonnement par site des prestataires (M3 § 2.13) : la permission
     * `sites.bypass_scope` est attribuee par defaut a Bureau / Compta /
     * Commerciale (ils voient « Tout », d'apres le docx) ; Usine ne l'a PAS et
     * reste cloisonnee a son site courant. Admin a le bypass total via isAdmin.
     * C'est un cloisonnement pilote par user/permission, pas par code de role :
     * pour cloisonner Bureau/Commerciale, il suffit de retirer la permission
     * ici, aucun autre code a changer.
     *
     * @var array<string, array{label: string, permissions: list<string>}>
     */
    private const array MATRIX = [
        self::ROLE_BUREAU => [
            'label'       => 'Bureau',
            'permissions' => [
                'commercial.clients.view',
                'commercial.clients.manage',
                // Fournisseurs (M2 § 2.9, ERP-90) : view + manage (hors Comptabilite).
                'commercial.suppliers.view',
                'commercial.suppliers.manage',
                // Prestataires (M3 § 2.9, ERP-138) : view + manage (hors Comptabilite).
                'technique.providers.view',
                'technique.providers.manage',
                // Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
                'transport.carriers.view',
                'transport.carriers.manage',
                // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
                'sites.bypass_scope',
                // Lecture des referentiels transverses pour les selects client (ERP-102).
                'catalog.categories.read_ref',
                'sites.read_ref',
            ],
        ],
        self::ROLE_COMPTA => [
            'label'       => 'Comptabilité',
            'permissions' => [
                'commercial.clients.view',
                'commercial.clients.accounting.view',
                'commercial.clients.accounting.manage',
                // Fournisseurs (M2 § 2.9, ERP-90) : view + onglet Comptabilite uniquement
                // (pas de manage global -> ne peut pas creer un fournisseur).
                'commercial.suppliers.view',
                'commercial.suppliers.accounting.view',
                'commercial.suppliers.accounting.manage',
                // Prestataires (M3 § 2.9, ERP-138) : view + onglet Comptabilite uniquement
                // (pas de manage global -> ne peut pas creer un prestataire).
                'technique.providers.view',
                'technique.providers.accounting.view',
                'technique.providers.accounting.manage',
                // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
                'sites.bypass_scope',
                // Lecture des referentiels transverses pour les selects client (ERP-102).
                'catalog.categories.read_ref',
                'sites.read_ref',
            ],
        ],
        self::ROLE_COMMERCIALE => [
            'label'       => 'Commerciale',
            'permissions' => [
                'commercial.clients.view',
                'commercial.clients.manage',
                // Fournisseurs (M2 § 2.9, ERP-90) : view + manage, sans accounting
                // (onglet Comptabilite masque/filtre pour la Commerciale).
                'commercial.suppliers.view',
                'commercial.suppliers.manage',
                // Prestataires (M3 § 2.9, ERP-138) : view + manage, sans accounting
                // (onglet Comptabilite masque/filtre pour la Commerciale).
                'technique.providers.view',
                'technique.providers.manage',
                // Transporteurs (M4 § 5.2, ERP-153) : view seul (consultation « Tout »,
                // ni manage ni archive pour la Commerciale).
                'transport.carriers.view',
                // Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
                'sites.bypass_scope',
                // Lecture des referentiels transverses pour les selects client (ERP-102).
                'catalog.categories.read_ref',
                'sites.read_ref',
            ],
        ],
        self::ROLE_USINE => [
            'label' => 'Usine',
            // Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
            // SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
            // courant. Aucun autre acces metier.
            'permissions' => [
                'technique.providers.view',
            ],
        ],
    ];

    public function __construct(
        private readonly RoleRepositoryInterface $roleRepository,
        private readonly PermissionRepositoryInterface $permissionRepository,
        private readonly UserRepositoryInterface $userRepository,
        private readonly SiteProviderInterface $siteProvider,
        private readonly UserPasswordHasherInterface $passwordHasher,
    ) {}

    /**
     * Cree chaque role metier absent (lookup par code). Idempotent.
     *
     * @return list<string> codes des roles effectivement crees (vide au rejeu)
     */
    public function ensureRoles(): array
    {
        $created = [];

        foreach (self::MATRIX as $code => $definition) {
            if (null !== $this->roleRepository->findByCode($code)) {
                continue;
            }

            // isSystem=false : ce sont des roles metier, supprimables par un
            // admin (contrairement aux roles systeme admin/user).
            $this->roleRepository->save(new Role($code, $definition['label'], isSystem: false));
            $created[] = $code;
        }

        return $created;
    }

    /**
     * Attache la matrice § 2.7 a chaque role via la M2M role_permission. Lookup
     * de la permission par code ; un code absent leve une RbacSeedException
     * (garde-fou : `app:sync-permissions` doit avoir tourne). Idempotent : un
     * lien deja present n'est pas recree.
     *
     * @return int nombre de liens role->permission effectivement ajoutes (0 au rejeu)
     *
     * @throws RbacSeedException si un role ou une permission de la matrice manque
     */
    public function attachMatrix(): int
    {
        $added = 0;

        foreach (self::MATRIX as $code => $definition) {
            $role = $this->roleRepository->findByCode($code);
            if (null === $role) {
                throw RbacSeedException::missingRole($code);
            }

            $touched = false;
            foreach ($definition['permissions'] as $permissionCode) {
                $permission = $this->permissionRepository->findByCode($permissionCode);
                if (null === $permission) {
                    throw RbacSeedException::missingPermission($permissionCode);
                }

                if (!$role->getPermissions()->contains($permission)) {
                    $role->addPermission($permission);
                    $touched = true;
                    ++$added;
                }
            }

            // Un seul flush par role, et seulement si un lien a change.
            if ($touched) {
                $this->roleRepository->save($role);
            }
        }

        return $added;
    }

    /**
     * Cree un compte demo par role metier (username = code du role), non-admin,
     * mot de passe hashe, rattache a son role et a >= 1 site. Lookup par
     * username : idempotent (un compte existant est laisse intact, mot de passe
     * inchange).
     *
     * @return list<string> usernames effectivement crees (vide au rejeu)
     *
     * @throws RbacSeedException si un role metier attendu est absent (ensureRoles non joue)
     */
    public function ensureDemoUsers(string $password): array
    {
        // Rattachement a un site par defaut s'il existe (les flux login / me en
        // ont besoin ; le repertoire clients n'est pas site-scope mais on reste
        // coherent avec les fixtures admin/alice/bob).
        $defaultSite = $this->siteProvider->findByName(self::DEFAULT_SITE_NAME);
        $created     = [];

        foreach (array_keys(self::MATRIX) as $code) {
            $username = $code;
            if (null !== $this->userRepository->findByUsername($username)) {
                continue;
            }

            $role = $this->roleRepository->findByCode($code);
            if (null === $role) {
                throw RbacSeedException::missingRole($code);
            }

            $user = new User();
            $user->setUsername($username);
            $user->setIsAdmin(false);
            $user->setPassword($this->passwordHasher->hashPassword($user, $password));
            $user->addRbacRole($role);

            if (null !== $defaultSite) {
                $user->addSite($defaultSite);
                $user->setCurrentSite($defaultSite);
            }

            $this->userRepository->save($user);
            $created[] = $username;
        }

        return $created;
    }

    /**
     * Liste des codes des roles metier definis (pour reporting / tests).
     *
     * @return list<string>
     */
    public static function roleCodes(): array
    {
        return array_keys(self::MATRIX);
    }
}
