<?php

/**
 * The Ldap class provides methods and functions for LDAP and/or Active Directory.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public License,
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
 * obtain one at https://mozilla.org/MPL/2.0/.
 *
 * @package   phpMyFAQ
 * @author    Adam Greene <phpmyfaq@skippy.fastmail.fm>
 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
 * @author    Alberto Cabello Sanchez <alberto@unex.es>
 * @author    Lars Scheithauer <larsscheithauer@googlemail.com>
 * @copyright 2004-2026 phpMyFAQ Team
 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
 * @link      https://www.phpmyfaq.de
 * @since     2004-12-16
 */

declare(strict_types=1);

namespace phpMyFAQ;

use LDAP\Connection;
use SensitiveParameter;

/**
 * Class Ldap
 *
 * @package phpMyFAQ
 */
class Ldap
{
    /**
     * Error.
     */
    public ?string $error = null;

    /**
     * LDAP error number.
     */
    public ?int $errno = null;

    private readonly array $ldapConfig;

    /**
     * An LDAP link identifier, returned by ldap_connect()
     *
     * @var resource|false
     */
    private Connection|bool|null $ds = null;

    /**
     * The LDAP base.
     */
    private ?string $base = null;

    /**
     * Constructor.
     */
    public function __construct(
        private readonly Configuration $configuration,
    ) {
        $this->ldapConfig = $this->configuration->getLdapConfig();
    }

    /**
     * Connects to a given LDAP server with given credentials.
     */
    public function connect(
        string $ldapServer,
        int $ldapPort,
        string $ldapBase,
        string $ldapUser = '',
        #[SensitiveParameter]
        string $ldapPassword = '',
    ): bool {
        // Sanity checks
        if ('' === $ldapServer || '' === $ldapBase) {
            return false;
        }

        $this->base = $ldapBase;
        $this->ds = ldap_connect($ldapServer . ':' . $ldapPort);

        if (!$this->ds) {
            $this->error = 'Unable to connect to LDAP server';

            return false;
        }

        // Set LDAP options
        foreach ($this->configuration->getLdapOptions() as $key => $ldapOption) {
            if (ldap_set_option($this->ds, constant($key), $ldapOption)) {
                continue;
            }

            $this->errno = ldap_errno($this->ds);
            $errorMessage = 'Unable to set LDAP option "%s" to "%s" (Error: %s).';
            $this->error = sprintf($errorMessage, $key, $ldapOption, ldap_error($this->ds));

            return false;
        }

        if ($this->configuration->get(item: 'ldap.ldap_use_dynamic_login')) {
            // Dynamic user binding: construct RDN from login attribute
            $loginAttribute = $this->configuration->get(item: 'ldap.ldap_dynamic_login_attribute');
            $ldapRdn = $loginAttribute . '=' . $ldapUser . ',' . $ldapBase;
            $ldapBind = $this->bind($ldapRdn, $ldapPassword);
        } elseif ($this->configuration->get(item: 'ldap.ldap_use_anonymous_login')) {
            // Anonymous binding
            $ldapBind = $this->bind();
        } else {
            // Default: user binding without RDN
            $ldapBind = $this->bind($ldapUser, $ldapPassword);
        }

        if (false === $ldapBind) {
            $this->errno = ldap_errno($this->ds);
            $this->error = sprintf('Unable to bind to LDAP server (Error: %s).', ldap_error($this->ds));
            $this->ds = false;

            return false;
        }

        return true;
    }

    /**
     * Binds to the LDAP directory with specified RDN and password.
     */
    public function bind(string $rdn = '', #[SensitiveParameter] string $password = ''): bool
    {
        if ($this->ds === false) {
            $this->error = 'The LDAP connection handler is not a valid resource.';

            return false;
        }

        if ('' === $rdn && '' === $password) {
            return ldap_bind($this->ds);
        }

        return ldap_bind($this->ds, $rdn, $password);
    }

    /**
     * Returns the user's email address from LDAP.
     *
     * @param string $username Username
     */
    public function getMail(string $username): bool|string
    {
        return $this->getLdapData($username, data: 'mail');
    }

    /**
     * Returns specific data from LDAP.
     *
     * @param string $username Username
     * @param string $data     MapKey
     */
    private function getLdapData(string $username, string $data): bool|string
    {
        if ($this->ds === false || $this->ds === null) {
            $this->error = 'The LDAP connection handler is not a valid resource.';

            return false;
        }

        if ($this->base === null || $this->base === '') {
            $this->error = 'LDAP base DN is not configured.';

            return false;
        }

        if (!array_key_exists($data, $this->ldapConfig['ldap_mapping'])) {
            $errorMessage = 'The requested data field "%s" does not exist in LDAP mapping configuration.';
            $this->error = sprintf($errorMessage, $data);

            return false;
        }

        $comparison = '(%s=%s)';
        $filter = sprintf(
            $comparison,
            $this->configuration->get(item: 'ldap.ldap_mapping.username'),
            $this->quote($username),
        );

        if ($this->configuration->get(item: 'ldap.ldap_use_memberOf')) {
            $comparison = '(&%s(memberOf:1.2.840.113556.1.4.1941:=%s))';
            $filter = sprintf($comparison, $filter, $this->configuration->get(item: 'ldap.ldap_mapping.memberOf'));
        }

        $fields = [$this->ldapConfig['ldap_mapping'][$data]];

        $searchResult = ldap_search($this->ds, $this->base, $filter, $fields);

        if (!$searchResult) {
            $errorMessage = 'Unable to search for "%s" (Error: %s)';
            $this->error = sprintf($errorMessage, $username, ldap_error($this->ds));

            return false;
        }

        $entryId = ldap_first_entry($this->ds, $searchResult);

        if (!$entryId) {
            $this->errno = ldap_errno($this->ds);
            $this->error = sprintf('Cannot get the value(s). Error: %s', ldap_error($this->ds));

            return false;
        }

        $entries = ldap_get_entries($this->ds, $searchResult);
        for ($i = 0; $i < $entries['count']; ++$i) {
            if (!isset($entries[$i][$fields[0]][0])) {
                continue;
            }

            return $entries[$i][$fields[0]][0];
        }

        return false;
    }

    /**
     * Quotes LDAP strings in accordance with the RFC 2254.
     */
    public function quote(string $string): string
    {
        return str_replace(['\\', ' ', '*', '(', ')'], ['\\5c', '\\20', '\\2a', '\\28', '\\29'], $string);
    }

    /**
     * Returns the user's DN.
     *
     * @param string $username Username
     */
    public function getDn(string $username): bool|string
    {
        return $this->getLdapDn($username);
    }

    /**
     * Returns the DN from LDAP.
     *
     * @param string $username Username
     */
    private function getLdapDn(string $username): string|false
    {
        if ($this->ds === false || $this->ds === null) {
            $this->error = 'The LDAP connection handler is not a valid resource.';

            return false;
        }

        if ($this->base === null || $this->base === '') {
            $this->error = 'LDAP base DN is not configured.';

            return false;
        }

        $comparison = '(%s=%s)';
        $filter = sprintf(
            $comparison,
            $this->configuration->get(item: 'ldap.ldap_mapping.username'),
            $this->quote($username),
        );
        $sr = ldap_search($this->ds, $this->base, $filter);

        if (false === $sr) {
            $errorMessage = 'Unable to search for "%s" (Error: %s)';
            $this->error = sprintf($errorMessage, $username, ldap_error($this->ds));

            return false;
        }

        $entryId = ldap_first_entry($this->ds, $sr);

        if (false === $entryId) {
            $this->error = sprintf('Cannot get the value(s). Error: %s', ldap_error($this->ds));

            return false;
        }

        return ldap_get_dn($this->ds, $entryId);
    }

    /**
     * Returns the user's full name from LDAP.
     *
     * @param string $username Username
     */
    public function getCompleteName(string $username): bool|string
    {
        return $this->getLdapData($username, data: 'name');
    }

    /**
     * Returns the user's AD group memberships.
     *
     * @param string $username Username
     * @return array<string>|false Array of group DNs or false on error
     */
    public function getGroupMemberships(string $username): array|false
    {
        if ($this->ds === false || $this->ds === null) {
            $this->error = 'The LDAP connection handler is not a valid resource.';
            return false;
        }

        if ($this->base === null || $this->base === '') {
            $this->error = 'LDAP base DN is not configured.';
            return false;
        }

        $comparison = '(%s=%s)';
        $filter = sprintf(
            $comparison,
            $this->configuration->get(item: 'ldap.ldap_mapping.username'),
            $this->quote($username),
        );

        $fields = ['memberOf'];

        $searchResult = ldap_search($this->ds, $this->base, $filter, $fields);

        if (!$searchResult) {
            $errorMessage = 'Unable to search for "%s" (Error: %s)';
            $this->error = sprintf($errorMessage, $username, ldap_error($this->ds));

            return false;
        }

        $entryId = ldap_first_entry($this->ds, $searchResult);

        if (!$entryId) {
            $this->errno = ldap_errno($this->ds);
            $this->error = sprintf('Cannot get the value(s). Error: %s', ldap_error($this->ds));

            return false;
        }

        $entries = ldap_get_entries($this->ds, $searchResult);
        $groups = [];

        if ($entries['count'] > 0 && isset($entries[0]['memberof'])) {
            for ($i = 0; $i < $entries[0]['memberof']['count']; $i++) {
                $groups[] = $entries[0]['memberof'][$i];
            }
        }

        return $groups;
    }

    /**
     * Returns the LDAP error message of the last LDAP command.
     *
     * @param resource $ds LDAP resource
     */
    public function error($ds = null): string
    {
        if ($ds === null) {
            $ds = $this->ds;
        }

        return ldap_error($ds);
    }
}
