<?php
/**
 * Auth - Authentication library
 * Supports: local accounts, OAuth (configurable), LDAP (configurable)
 */

class Auth
{
    private Database $db;
    private Logger   $log;
    private User     $userModel;

    public function __construct()
    {
        $this->db        = Database::getInstance();
        $this->log       = Logger::getInstance();
        $this->userModel = new User();
    }

    // -------------------------------------------------------------------------
    // Authentication dispatch
    // -------------------------------------------------------------------------

    public function attempt(string $username, string $password, string $method = AUTH_LOCAL): array|false
    {
        $username = strtolower(trim($username));

        if (!$this->isMethodEnabled($method)) {
            $this->log->warning("Auth method disabled", ['method' => $method]);
            return false;
        }

        return match($method) {
            AUTH_LOCAL => $this->attemptLocal($username, $password),
            AUTH_LDAP  => $this->attemptLdap($username, $password),
            default    => false,
        };
    }

    // -------------------------------------------------------------------------
    // Local authentication
    // -------------------------------------------------------------------------

    private function attemptLocal(string $username, string $password): array|false
    {
        $this->log->debug("Local auth attempt", ['username' => $username]);

        $user = $this->userModel->findByUsername($username);

        if (!$user) {
            $this->log->warning("Login failed: unknown user", ['username' => $username]);
            return false;
        }

        if (!$user['active']) {
            $this->log->warning("Login failed: inactive user", ['username' => $username]);
            return false;
        }

        if ($user['auth_method'] !== AUTH_LOCAL) {
            $this->log->warning("Login failed: wrong auth method", ['username' => $username]);
            return false;
        }

        if (!password_verify($password, $user['password_hash'] ?? '')) {
            $this->log->warning("Login failed: bad password", ['username' => $username]);
            return false;
        }

        if (empty($user['roles'])) {
            $this->log->warning("Login denied: no role assigned", ['username' => $username]);
            return false;
        }

        $this->userModel->updateLastLogin($user['id']);
        $this->log->info("Login success (local)", ['username' => $username]);
        return $user;
    }

    // -------------------------------------------------------------------------
    // LDAP authentication
    // -------------------------------------------------------------------------

    private function attemptLdap(string $username, string $password): array|false
    {
        $config = $this->getMethodConfig(AUTH_LDAP);
        if (!$config || empty($config['host'])) {
            $this->log->error("LDAP not configured");
            return false;
        }

        if (!function_exists('ldap_connect')) {
            $this->log->error("LDAP extension not available");
            return false;
        }

        // Déterminer si l'input est un email ou un SAN
        $inputIsEmail = str_contains($username, '@');
        $email = $inputIsEmail ? strtolower($username) : null;

        $port = (int)($config['port'] ?? 389);
        $uri  = self::buildLdapUri($config['host'], $port);

        if (!empty($config['tls_insecure'])) {
            @ldap_set_option(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
        }

        $conn = @ldap_connect($uri);
        if (!$conn) {
            $this->log->error("LDAP connection failed", ['host' => $config['host']]);
            return false;
        }

        ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
        ldap_set_option($conn, LDAP_OPT_REFERRALS, 0);
        @ldap_set_option($conn, LDAP_OPT_NETWORK_TIMEOUT, 5);

        // Sécurité transport : sur une connexion non-LDAPS, tenter StartTLS de
        // façon opportuniste (chiffre le bind si le serveur le supporte). Non
        // fatal pour ne pas verrouiller un annuaire sans TLS ; un bind en clair
        // est journalisé comme avertissement (le mot de passe transiterait sinon
        // en clair). Utiliser ldaps:// (port 636) est recommandé en production.
        if (!self::isLdapsUri($uri)) {
            if (@ldap_start_tls($conn)) {
                $this->log->debug('LDAP StartTLS établi');
            } else {
                $this->log->warning('LDAP StartTLS indisponible — bind potentiellement en clair', [
                    'host' => $config['host'], 'errno' => @ldap_errno($conn)]);
            }
        }

        $baseDnRaw = $config['base_dn'] ?? '';
        // searchDn : format DC= pour ldap_search
        $searchDn = $baseDnRaw;
        if ($searchDn !== '' && !preg_match('/DC=/i', $searchDn)) {
            $searchDn = implode(',', array_map(fn($p) => 'DC=' . $p, explode('.', $searchDn)));
        }

        // Si input = email, résoudre le SAN via l'AD (bind de service si dispo,
        // sinon recherche anonyme). Le domaine mail ≠ domaine AD, on ne peut
        // pas déduire le SAN du @.
        if ($inputIsEmail) {
            // Bind de service pour la recherche
            $bindDn   = $config['bind_dn']       ?? '';
            $bindPass = Crypto::decrypt($config['bind_password'] ?? '') ?? '';
            if ($bindDn !== '' && @ldap_bind($conn, $bindDn, $bindPass)) {
                // OK — connecté avec le compte de service
            } else {
                // Tenter une recherche anonyme
                @ldap_bind($conn);
            }

            $emailFilter = ldap_escape($email, '', LDAP_ESCAPE_FILTER);
            $sr = @ldap_search($conn, $searchDn, "(mail={$emailFilter})", ['sAMAccountName'], 0, 1);
            if ($sr) {
                $entries = ldap_get_entries($conn, $sr);
                if (($entries['count'] ?? 0) > 0 && !empty($entries[0]['samaccountname'][0])) {
                    $username = strtolower($entries[0]['samaccountname'][0]);
                    $this->log->debug("LDAP email resolved to SAN", ['email' => $email, 'san' => $username]);
                } else {
                    $this->log->warning("LDAP login by email: SAN not found in AD", ['email' => $email]);
                    ldap_close($conn);
                    return false;
                }
            } else {
                $this->log->warning("LDAP search by email failed", ['email' => $email]);
                ldap_close($conn);
                return false;
            }
        }

        // Bind avec le SAN résolu + mot de passe utilisateur
        // buildUserDn reçoit le base_dn ORIGINAL (domaine) pour que {domain} fonctionne
        $pattern = $config['user_dn_pattern'] ?? 'uid={user},{base_dn}';
        $dn      = self::buildUserDn($pattern, $username, $baseDnRaw);
        $this->log->debug("LDAP user bind attempt", ['dn' => $dn, 'username' => $username]);
        if (!@ldap_bind($conn, $dn, $password)) {
            $this->log->warning("LDAP bind failed", [
                'username' => $username, 'dn' => $dn,
                'errno' => @ldap_errno($conn), 'error' => ldap_error($conn)]);
            ldap_close($conn);
            return false;
        }

        // Récupérer l'adresse mail depuis l'annuaire LDAP (attribut mail)
        $ldapEmail = null;
        $sanFilter = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
        $sr = @ldap_search($conn, $searchDn, "(sAMAccountName={$sanFilter})", ['mail'], 0, 1);
        if ($sr) {
            $entries = ldap_get_entries($conn, $sr);
            if (($entries['count'] ?? 0) > 0 && !empty($entries[0]['mail'][0])) {
                $ldapEmail = strtolower($entries[0]['mail'][0]);
            }
        }
        ldap_close($conn);

        // Priorité : email LDAP > email saisi > null
        if ($ldapEmail) $email = $ldapEmail;

        // Find or create the user in local DB. L'utilisateur créé n'a aucun rôle ;
        // l'administrateur doit les assigner explicitement via Administration → Utilisateurs.
        $user = $this->userModel->findByUsername($username);
        if (!$user) {
            $id = $this->userModel->create([
                'username'    => $username,
                'email'       => $email,
                'auth_method' => AUTH_LDAP,
            ]);
            $user = $this->userModel->findById($id);
        } elseif ($email && ($user['email'] ?? '') !== $email) {
            // Mettre à jour l'email si changé dans l'AD
            $this->userModel->update($user['id'], ['email' => $email]);
            $user['email'] = $email;
        }

        if (!$user || !$user['active']) {
            return false;
        }

        if (empty($user['roles'])) {
            $this->log->warning("Login denied: no role assigned", ['username' => $username]);
            return false;
        }

        $this->userModel->updateLastLogin($user['id']);
        $this->log->info("Login success (LDAP)", ['username' => $username]);
        return $user;
    }

    // -------------------------------------------------------------------------
    // LDAP helpers (réutilisés par mon-appli/admin/test_ldap.php et users.php)
    // -------------------------------------------------------------------------

    /**
     * Cherche un utilisateur dans l'annuaire LDAP par SAN ou par email.
     * Retourne ['san' => ..., 'email' => ...] ou null si introuvable.
     */
    public function lookupLdapUser(string $input): ?array
    {
        $config = $this->getMethodConfig(AUTH_LDAP);
        if (!$config || empty($config['host']) || !function_exists('ldap_connect')) {
            $this->log->debug("lookupLdapUser: LDAP not configured or extension missing");
            return null;
        }

        $port        = (int)($config['port'] ?? 389);
        $uri         = self::buildLdapUri($config['host'], $port);
        $tlsInsecure = !empty($config['tls_insecure']);

        if ($tlsInsecure) {
            @ldap_set_option(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
        }

        $conn = @ldap_connect($uri);
        if (!$conn) {
            $this->log->debug("lookupLdapUser: ldap_connect failed", ['uri' => $uri]);
            return null;
        }

        ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
        ldap_set_option($conn, LDAP_OPT_REFERRALS, 0);
        @ldap_set_option($conn, LDAP_OPT_NETWORK_TIMEOUT, 5);

        // Bind de service
        $bindDn   = $config['bind_dn']       ?? '';
        $bindPass = Crypto::decrypt($config['bind_password'] ?? '') ?? '';
        $this->log->debug("lookupLdapUser: attempting bind", [
            'uri' => $uri, 'bind_dn' => $bindDn, 'has_pass' => $bindPass !== '']);
        if ($bindDn !== '') {
            if (!@ldap_bind($conn, $bindDn, $bindPass)) {
                $errno = @ldap_errno($conn);
                $this->log->debug("lookupLdapUser: service bind failed", [
                    'bind_dn' => $bindDn, 'errno' => $errno, 'error' => ldap_error($conn)]);
                ldap_close($conn);
                return null;
            }
        } else {
            @ldap_bind($conn);
        }
        $this->log->debug("lookupLdapUser: bind OK");

        $baseDn = $config['base_dn'] ?? '';
        // Si le base_dn est un nom de domaine (pas de DC=), le convertir
        if ($baseDn !== '' && !preg_match('/DC=/i', $baseDn)) {
            $baseDn = implode(',', array_map(fn($p) => 'DC=' . $p, explode('.', $baseDn)));
        }

        // Chercher par SAN ou par email selon l'input (input échappé — filtre LDAP)
        $inputFilter = ldap_escape($input, '', LDAP_ESCAPE_FILTER);
        $filter = str_contains($input, '@')
            ? "(mail={$inputFilter})"
            : "(sAMAccountName={$inputFilter})";

        $this->log->debug("lookupLdapUser: searching", [
            'base_dn' => $baseDn, 'filter' => $filter]);

        $sr = @ldap_search($conn, $baseDn, $filter, ['sAMAccountName', 'mail'], 0, 1);
        if (!$sr) {
            $this->log->debug("lookupLdapUser: ldap_search failed", [
                'errno' => @ldap_errno($conn), 'error' => ldap_error($conn)]);
            ldap_close($conn);
            return null;
        }

        $entries = ldap_get_entries($conn, $sr);
        ldap_close($conn);

        $this->log->debug("lookupLdapUser: results", ['count' => $entries['count'] ?? 0]);

        if (($entries['count'] ?? 0) === 0) return null;

        $entry = $entries[0];
        return [
            'san'   => strtolower($entry['samaccountname'][0] ?? ''),
            'email' => strtolower($entry['mail'][0] ?? ''),
        ];
    }

    /**
     * Raccourci : cherche l'email LDAP par SAN. Retourne null si introuvable.
     */
    public function lookupLdapEmail(string $username): ?string
    {
        $result = $this->lookupLdapUser($username);
        return $result['email'] ?? null;
    }

    /**
     * Construit l'URI LDAP/LDAPS attendue par ldap_connect().
     *  - host avec préfixe ldap:// ou ldaps:// → renvoyé tel quel (port ajouté si absent)
     *  - port 636 sans préfixe → ldaps://host:636
     *  - sinon → ldap://host:port
     */
    public static function buildLdapUri(string $host, int $port): string
    {
        if (preg_match('#^ldaps?://#i', $host)) {
            return preg_match('/:\d+$/', $host) ? $host : "$host:$port";
        }
        $scheme = ($port === 636) ? 'ldaps' : 'ldap';
        return "$scheme://$host:$port";
    }

    public static function isLdapsUri(string $uri): bool
    {
        return (bool) preg_match('#^ldaps://#i', $uri);
    }

    /**
     * Construit le DN d'authentification à partir d'un pattern.
     *
     * Placeholders supportés :
     *   {user}     → nom d'utilisateur saisi
     *   {base_dn}  → Base DN configuré
     *   {domain}   → domaine extrait des composants DC= du Base DN (ex. mg.com.fr)
     *
     * Patterns courants :
     *   uid={user},{base_dn}                  ← OpenLDAP / POSIX (défaut)
     *   {user}@{domain}                       ← Active Directory (UPN)
     *   CN={user},OU=Users,{base_dn}          ← AD avec OU Users
     */
    public static function buildUserDn(string $pattern, string $username, string $baseDn): string
    {
        if ($pattern === '') $pattern = 'uid={user},{base_dn}';
        $domain = '';
        if (preg_match_all('/DC=([^,]+)/i', $baseDn, $m)) {
            $domain = strtolower(implode('.', $m[1]));
        }
        // {user} est échappé pour un contexte DN afin d'empêcher l'injection de
        // composantes (`,`, `=`, `+`…). Sur un username normal, c'est un no-op.
        return strtr($pattern, [
            '{user}'    => ldap_escape($username, '', LDAP_ESCAPE_DN),
            '{base_dn}' => $baseDn,
            '{domain}'  => $domain,
        ]);
    }

    // -------------------------------------------------------------------------
    // OAuth
    // -------------------------------------------------------------------------

    public function getOAuthRedirectUrl(): ?string
    {
        if (!$this->isMethodEnabled(AUTH_OAUTH)) {
            return null;
        }
        $config = $this->getMethodConfig(AUTH_OAUTH);
        if (!$config || empty($config['auth_url'])) {
            return null;
        }

        $params = http_build_query([
            'client_id'     => $config['client_id'],
            'redirect_uri'  => $config['redirect_uri'],
            'response_type' => 'code',
            'scope'         => 'openid email profile',
            'state'         => $this->generateState(),
        ]);
        return $config['auth_url'] . '?' . $params;
    }

    public function handleOAuthCallback(string $code, string $state): array|false
    {
        if (!$this->validateState($state)) {
            $this->log->warning("OAuth state mismatch");
            return false;
        }

        $config = $this->getMethodConfig(AUTH_OAUTH);
        if (!$config) {
            return false;
        }

        // Exchange code for token — déchiffrer le client_secret stocké (enc_v1:...)
        $response = $this->httpPost($config['token_url'], [
            'grant_type'    => 'authorization_code',
            'code'          => $code,
            'redirect_uri'  => $config['redirect_uri'],
            'client_id'     => $config['client_id'],
            'client_secret' => Crypto::decrypt($config['client_secret'] ?? '') ?? '',
        ]);

        if (!$response || empty($response['access_token'])) {
            $this->log->error("OAuth token exchange failed");
            return false;
        }

        // Get user info (generic OIDC userinfo endpoint). Prefer an explicit
        // userinfo_url ; sinon dériver en retirant *le suffixe* /authorize
        // (rtrim retirerait un jeu de caractères, pas le suffixe → URL erronée).
        $userinfoUrl = !empty($config['userinfo_url'])
            ? $config['userinfo_url']
            : preg_replace('#/authorize/?$#', '', $config['auth_url']) . '/userinfo';
        $userinfo = $this->httpGet($userinfoUrl, $response['access_token']);

        if (!$userinfo || empty($userinfo['sub'])) {
            return false;
        }

        $username = $userinfo['preferred_username'] ?? $userinfo['email'] ?? $userinfo['sub'];
        $email    = $userinfo['email'] ?? null;

        // Idem LDAP : pas de rôle attribué automatiquement à la création.
        $username = strtolower($username);
        $user = $this->userModel->findByUsername($username);
        if (!$user) {
            $id = $this->userModel->create([
                'username'    => $username,
                'email'       => $email,
                'auth_method' => AUTH_OAUTH,
            ]);
            $user = $this->userModel->findById($id);
        }

        if (!$user || !$user['active']) {
            return false;
        }

        if (empty($user['roles'])) {
            $this->log->warning("Login denied: no role assigned", ['username' => $username]);
            return false;
        }

        $this->userModel->updateLastLogin($user['id']);
        $this->log->info("Login success (OAuth)", ['username' => $username]);
        return $user;
    }

    // -------------------------------------------------------------------------
    // Session helpers
    // -------------------------------------------------------------------------

    public function loginUser(array $user): void
    {
        Session::set('user_id',  $user['id']);
        Session::set('username', $user['username']);
        Session::set('roles',    $user['roles'] ?? []);
        Session::set('language', $user['language'] ?? null);
        Session::set('theme',    $user['theme']    ?? null);
        session_regenerate_id(true);
    }

    public function logout(): void
    {
        $this->log->info("Logout", ['username' => Session::get('username')]);
        Session::destroy();
    }

    public function getCurrentUser(): ?array
    {
        $id = Session::get('user_id');
        if (!$id) {
            return null;
        }
        return $this->userModel->findById((int)$id);
    }

    public function isLoggedIn(): bool
    {
        return Session::has('user_id');
    }

    public function requireLogin(): void
    {
        if (!$this->isLoggedIn()) {
            header('Location: /login.php?redirect=' . urlencode($_SERVER['REQUEST_URI']));
            exit;
        }
    }

    /**
     * Exige que l'utilisateur ait au moins un des rôles donnés.
     * app_admin est un super-rôle implicite : il valide toujours.
     */
    public function requireRole(string ...$roles): void
    {
        $this->requireLogin();
        if ($this->hasAnyRole(...$roles)) return;

        http_response_code(403);
        include WEBROOT_PATH . '/includes/403.php';
        exit;
    }

    /** Renvoie true si l'utilisateur courant a le rôle (ou app_admin). */
    public function hasRole(string $role): bool
    {
        $userRoles = Session::get('roles', []);
        if (!is_array($userRoles)) return false;
        if (in_array(ROLE_APP_ADMIN, $userRoles, true)) return true;
        return in_array($role, $userRoles, true);
    }

    /** Renvoie true si l'utilisateur a au moins un des rôles donnés (ou app_admin). */
    public function hasAnyRole(string ...$roles): bool
    {
        $userRoles = Session::get('roles', []);
        if (!is_array($userRoles)) return false;
        if (in_array(ROLE_APP_ADMIN, $userRoles, true)) return true;
        foreach ($roles as $r) {
            if (in_array($r, $userRoles, true)) return true;
        }
        return false;
    }

    // -------------------------------------------------------------------------
    // Auth method configuration
    // -------------------------------------------------------------------------

    public function getEnabledMethods(): array
    {
        $rows = $this->db->fetchAll("SELECT method FROM auth_config WHERE enabled = 1");
        return array_column($rows, 'method');
    }

    public function isMethodEnabled(string $method): bool
    {
        $row = $this->db->fetchOne("SELECT enabled FROM auth_config WHERE method = ?", [$method]);
        return $row && (bool)$row['enabled'];
    }

    public function getMethodConfig(string $method): ?array
    {
        $row = $this->db->fetchOne("SELECT config FROM auth_config WHERE method = ?", [$method]);
        if (!$row || !$row['config']) {
            return null;
        }
        return json_decode($row['config'], true);
    }

    public function setMethodEnabled(string $method, bool $enabled): void
    {
        $this->db->execute(
            "UPDATE auth_config SET enabled = ? WHERE method = ?",
            [(int)$enabled, $method]
        );
    }

    public function updateMethodConfig(string $method, array $config): void
    {
        $this->db->execute(
            "UPDATE auth_config SET config = ? WHERE method = ?",
            [json_encode($config), $method]
        );
    }

    // -------------------------------------------------------------------------
    // Internals
    // -------------------------------------------------------------------------

    private function generateState(): string
    {
        $state = bin2hex(random_bytes(16));
        Session::set('oauth_state', $state);
        return $state;
    }

    private function validateState(string $state): bool
    {
        $stored = Session::get('oauth_state');
        Session::remove('oauth_state');
        return $stored && hash_equals($stored, $state);
    }

    /** Options TLS explicites : vérifie la chaîne et le nom d'hôte du pair. */
    private static function tlsContextOptions(): array
    {
        return [
            'verify_peer'       => true,
            'verify_peer_name'  => true,
            'allow_self_signed' => false,
        ];
    }

    private function httpPost(string $url, array $data): ?array
    {
        $ctx = stream_context_create([
            'http' => [
                'method'  => 'POST',
                'header'  => "Content-Type: application/x-www-form-urlencoded\r\n",
                'content' => http_build_query($data),
                'timeout' => 10,
            ],
            'ssl' => self::tlsContextOptions(),
        ]);
        $body = @file_get_contents($url, false, $ctx);
        if ($body === false) {
            $this->log->warning('OAuth httpPost failed', ['url' => $url]);
            return null;
        }
        return json_decode($body, true) ?: null;
    }

    private function httpGet(string $url, string $token): ?array
    {
        $ctx = stream_context_create([
            'http' => [
                'header'  => "Authorization: Bearer $token\r\n",
                'timeout' => 10,
            ],
            'ssl' => self::tlsContextOptions(),
        ]);
        $body = @file_get_contents($url, false, $ctx);
        if ($body === false) {
            $this->log->warning('OAuth httpGet failed', ['url' => $url]);
            return null;
        }
        return json_decode($body, true) ?: null;
    }
}
