<?php

namespace PrestaShop\Module\PolarisPrestaConnector;

use Jumbojett\OpenIDConnectClient;
use PolarisPrestaConnector;

/**
 * Service d'authentification OIDC pour la backdoor SSO de l'équipe technique.
 *
 * Permet aux techniciens VEGA / LCV de se connecter au backoffice Prestashop
 * d'un site équipé du module sans avoir à créer un compte local : ils
 * s'authentifient via Authentik (OpenID Connect, code flow + PKCE, client public)
 * et le module ouvre/promeut un compte admin local en conséquence.
 *
 * Règles métier :
 *   - Le bridge actif (LCV ou Polaris) détermine le client OIDC à utiliser.
 *   - L'utilisateur se voit attribuer le compte admin local correspondant à
 *     son domaine email :
 *       *@vega-info.fr      -> admin@vega-info.fr
 *       *@lcvmultimedia.fr  -> admin@lcvmultimedia.fr
 *   - Le compte est créé s'il n'existe pas, promu Super-admin s'il ne l'est pas,
 *     et attaché à toutes les boutiques en multistore.
 *
 * Sécurité :
 *   - PKCE S256 (client public, pas de secret).
 *   - State aléatoire en session pour la protection CSRF.
 *   - Nonce vérifié dans l'id_token.
 *   - Validation complète de l'id_token (signature JWKS, iss, aud, exp, iat, nonce).
 *   - email_verified obligatoire dans le claim.
 *   - Domaine email strictement whitelisté.
 */
class OidcAuthenticator
{
    /**
     * URL de base de l'Authentik VEGA
     */
    private const AUTHENTIK_BASE = 'https://auth.vega-info.fr';

    /**
     * Configuration par bridge.
     *
     * Map { prefix_bridge => [ issuer, client_id, button_label, admin_email, email_domain ] }
     *
     * - issuer       : URL de découverte OIDC (sans le .well-known/openid-configuration)
     * - client_id    : application Authentik côté serveur
     * - button_label : libellé affiché sur le bouton de la page de login
     * - admin_email  : email du compte admin local correspondant
     * - email_domain : domaine email autorisé (validé strictement)
     */
    private const BRIDGE_CONFIG = [
        'lcv' => [
            'issuer'       => self::AUTHENTIK_BASE . '/application/o/prestashop-lcv/',
            'client_id'    => 'NLq8sSsQV9T06Y8UEdIeM551YAn3ugl2Y3iUZ9jh',
            'button_label' => 'Connexion SSO LCV',
            'admin_email'  => 'admin@lcvmultimedia.fr',
            'email_domain' => 'lcvmultimedia.fr',
        ],
        'pol' => [
            'issuer'       => self::AUTHENTIK_BASE . '/application/o/prestashop-vega/',
            'client_id'    => '04JgnxYtkJM5tYmOm487zXKxZ52z3BPBarTSnHsG',
            'button_label' => 'Connexion SSO VEGA',
            'admin_email'  => 'admin@vega-info.fr',
            'email_domain' => 'vega-info.fr',            
        ],
    ];    

    private PolarisPrestaConnector $module;
    private array $bridgeConfig;

    public function __construct(PolarisPrestaConnector $module)
    {
        $this->module = $module;

        $prefix = $this->module->BridgePrefix;
        if (!isset(self::BRIDGE_CONFIG[$prefix]))
            throw new \RuntimeException('Bridge non supporté pour le SSO OIDC : ' . $prefix);

        $this->bridgeConfig = self::BRIDGE_CONFIG[$prefix];
    }

    /**
     * Indique si la backdoor SSO est activée par configuration.
     */
    public function isEnabled() : bool
    {
        $enabled = $this->module->getCfg()->get(SyncConfiguration::CFG_OIDC_ENABLED);
        return $enabled !== false;  // null compté comme activé (défaut)
    }

    /**
     * Libellé du bouton à afficher sur la page de login.
     */
    public function getButtonLabel() : string
    {
        return $this->bridgeConfig['button_label'];
    }

    /**
     * Construit l'URL absolue du point de départ du SSO via le routeur Symfony
     * (gère friendly URLs, multistore, multilangue, SSL).
     *
     * Cible la route admin anonyme du module sans paramètre 'step' — l'étape
     * 'login' est la valeur par défaut côté contrôleur.
     */
    public function getLoginUri() : string
    {
        return $this->generateRouteUri([]);
    }

    /**
     * Construit l'URL absolue de callback OIDC via le routeur Symfony.
     * Cette URL doit correspondre exactement (ou être couverte par un
     * wildcard) au redirect_uri enregistré côté Authentik.
     *
     * Cible la même route que getLoginUri() mais avec step=callback.
     */
    public function getRedirectUri() : string
    {
        return $this->generateRouteUri(['step' => 'callback']);
    }

    /**
     * Génère l'URL absolue de la route admin OIDC.
     *
     * Reproduit la mécanique utilisée dans InformationType pour le cron,
     * y compris le contournement du bug PS 8 sur ABSOLUTE_URL et la
     * suppression du jeton _token (le redirect_uri OIDC doit être
     * strictement constant pour passer la validation Authentik).
     */
    private function generateRouteUri(array $params) : string
    {
        // Génération de l'URL absolue via le routeur symfony sans token
        $router = $this->module->get('router');
        $routeName = '_'.$this->module->name . '_oidc';
        $url = $router->generate(
            $routeName,
            $params,
            \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL
        );

        // Contournement du bug PS 8 sur ABSOLUTE_URL qui génère une URL relative
        if (str_starts_with($url, '/'))
        {
            $baseUrl = rtrim(\Tools::getShopDomain(true, true), '/');
            $url = $baseUrl . $url;
        }

        // Suppression de _token (présent malgré tout dans certaines configs)
        // — vital car le redirect_uri OIDC doit être strictement constant.
        $url = preg_replace('/([?&])_token=[^&]*(&|$)/', '$1', $url);

        return trim($url, '&?');
    }

    /**
     * Démarre le flow OIDC : génère state/nonce/PKCE, stocke en session,
     * redirige vers Authentik. Ne retourne jamais.
     */
    public function startLoginFlow() : void
    {
        $this->ensureSession();

        $client = $this->buildClient();

        // Configuration PKCE (client public)
        $client->setCodeChallengeMethod('S256');

        // Lance la redirection (la lib appelle exit())
        $client->authenticate();
    }

    /**
     * Traite le callback OIDC : vérifie state, échange le code, valide l'id_token,
     * vérifie le domaine email, retourne les claims utiles.
     *
     * @return array { email: string, name: string, claims: object }
     * @throws \RuntimeException en cas de validation invalide
     */
    public function handleCallback() : array
    {
        $this->ensureSession();
     
        $client = $this->buildClient();
        $client->setCodeChallengeMethod('S256');

        // Échange code -> tokens + validation complète de l'id_token
        // (signature JWKS, iss, aud, exp, iat, nonce)
        if (!$client->authenticate())
            throw new \RuntimeException('Échec de l\'authentification OIDC.');

        // Récupération des claims
        $claims = $client->getVerifiedClaims();
        $email  = $claims->email          ?? null;
        $name   = $claims->name           ?? ($claims->preferred_username ?? '');

        if (!$email)
            throw new \RuntimeException('Claim email manquant dans la réponse OIDC.');

        return [
            'email'     => $email,
            'name'      => $name,
            'username'  => $claims->preferred_username ?? $email,
            'claims'    => $claims,
        ];
    }

    /**
     * Email du compte admin local correspondant au bridge actif.
     */
    public function getAdminEmail() : string
    {
        return $this->bridgeConfig['admin_email'];
    }

    /**
     * Récupère ou crée le compte admin local, le promeut Super-admin si besoin,
     * l'attache à toutes les boutiques.
     *
     * @return \Employee
     */
    public function getOrCreateAdminEmployee() : \Employee
    {
        $adminEmail = $this->getAdminEmail();
        
        if (! \Employee::employeeExists($adminEmail)) {
            // L'employee n'existe pas, on le crée
            // Création
            $employee = new \Employee();
            $employee->firstname = 'Service technique ';
            $employee->lastname  = $this->module->getBridge()->getId();
            $employee->email     = $adminEmail;
            $employee->id_profile = _PS_ADMIN_PROFILE_;
            $employee->id_lang   = (int) \Configuration::get('PS_LANG_DEFAULT');

            // Mot de passe random fort, jamais utilisé pour se connecter
            // mais nécessaire en base (et resservira pour valider le cookie).
            $employee->setWsPasswd(\Tools::passwdGen(32));
            $employee->active = true;
            $employee->save();

            $this->attachEmployeeToAllShops((int) $employee->id);

            \PrestaShopLogger::addLog(
                sprintf('%sPolarisPrestaConnector SSO : compte admin local "%s" créé pour l\'utilisateur OIDC.',
                    $this->module->getBridge()->getId(),
                    $adminEmail),
                1
            );
        }        

        // Chargement de l'employee existant ou nouvellement créé
        $employee = new \Employee();
        $employee->getByEmail($adminEmail);

        // Promotion Super-admin si besoin
        if ((int) $employee->id_profile !== (int) _PS_ADMIN_PROFILE_ || !$employee->active)
        {
            $employee->id_profile = _PS_ADMIN_PROFILE_;
            $employee->active = true;
            $employee->save();

            $this->attachEmployeeToAllShops((int) $employee->id);

            \PrestaShopLogger::addLog(
                sprintf('%sPolarisPrestaConnector SSO : compte admin local "%s" promu Super-admin pour l\'utilisateur OIDC.',
                    $this->module->getBridge()->getId(),
                    $adminEmail),
                2
            );
        }

        return $employee;
    }

    // ── Helpers internes ─────────────────────────────────────────────────────

    /**
     * Construit le client OIDC jumbojett configuré pour ce bridge.
     */
    private function buildClient() : OpenIDConnectClient
    {
        $client = new OpenIDConnectClient(
            $this->bridgeConfig['issuer'],
            $this->bridgeConfig['client_id'],
            null  // client public, pas de secret
        );

        $client->setRedirectURL($this->getRedirectUri());
        $client->addScope(['openid', 'profile', 'email']);

        // Accepter SHA256 pour la signature des id_token (Authentik défaut)
        $client->setAllowImplicitFlow(false);
        $client->setResponseTypes(['code']);

        return $client;
    }

    /**
     * S'assure qu'une session PHP est démarrée (nécessaire pour stocker state/nonce/verifier).
     */
    private function ensureSession() : void
    {
        if (session_status() !== PHP_SESSION_ACTIVE)
            @session_start();
    }

    /**
     * Génère un code_verifier PKCE conforme RFC 7636 (43-128 chars URL-safe).
     */
    private function generatePkceVerifier() : string
    {
        return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
    }

    /**
     * Attache l'employee à toutes les boutiques actives (multistore safe).
     */
    private function attachEmployeeToAllShops(int $idEmployee) : void
    {
        $db = \Db::getInstance();

        // Purge les associations existantes pour repartir clean
        $db->execute('DELETE FROM `' . _DB_PREFIX_ . 'employee_shop` WHERE id_employee = ' . $idEmployee);

        // Récupère toutes les boutiques actives
        $shops = \Shop::getShops(true, null, true);
        foreach ($shops as $idShop)
        {
            $db->execute(
                'INSERT IGNORE INTO `' . _DB_PREFIX_ . 'employee_shop` (id_employee, id_shop) '
                . 'VALUES (' . $idEmployee . ', ' . (int) $idShop . ')'
            );
        }
    }
}