<?php

namespace EesyPHP\Auth;

use EesyPHP\App;
use EesyPHP\Auth\User;
use EesyPHP\Config;
use EesyPHP\Log;
use function EesyPHP\ensure_is_array;
use function EesyPHP\cast;
use function EesyPHP\vardump;

use PEAR;
use Net_LDAP2;
use Net_LDAP2_Filter;

class Ldap extends Backend {

  /**
   * LDAP configuration as expected by Net_LDAP2
   * @var array
   */
  private static $ldap_config;

  /**
   * Net_LDAP2 connection (if connected)
   * @var Net_LDAP2|null
   */
  private static $connection = null;

  /**
   * Initialize
   * @return bool
   */
  public static function init() {
    // Set default config values
    App :: set_default(
      'auth.ldap',
      array(
        'host' => array(),
        'port' => null,
        'basedn' => null,
        'binddn' => null,
        'bindpw' => null,
        'starttls' => false,
        'user_filter_by_uid' => 'uid=[username]',
        'user_basedn' => null,
        'bind_with_username' => false,
        'user_attributes' => array(
          'login' => array(
            'ldap_name' => 'uid',
            'type' => 'string',
            'multivalued' => false,
            'default' => null,
          ),
          'mail' => array(
            'type' => 'string',
            'multivalued' => false,
            'default' => null,
          ),
          'name' => array(
            'ldap_name' => 'displayName',
            'alt_ldap_name' => 'cn',
            'type' => 'string',
            'multivalued' => false,
            'default' => null,
          ),
        ),
        'netldap2_path' => 'Net/LDAP2.php',
      )
    );
    if (!class_exists('Net_LDAP2')) {
      $path = App::get('auth.ldap.netldap2_path', null, 'string');
      if (!@include($path)) {
        Log::error('Fail to load Net_LDAP2 (%s)', $path);
        return false;
      }
    }
    foreach(array('host', 'basedn') as $param) {
      if (!App::get("auth.ldap.$param")) {
        Log :: error('LDAP %s not configured. Check your configuration!', $param);
        return false;
      }
    }

    self :: $ldap_config = array (
      'host'  => implode(' ', App :: get('auth.ldap.host', null, 'array')),
      'basedn' => App :: get('auth.ldap.basedn', null, 'string'),
      'binddn' => App :: get('auth.ldap.bind_dn', null, 'string'),
      'bindpw' => App :: get('auth.ldap.bind_password', null, 'string'),
      'starttls' => App :: get('starttls', null, 'bool'),
    );
    if ($port = App :: get('auth.ldap.port', null, 'int'))
      self :: $ldap_config['port'] = $port;
    return true;
  }

  /**
   * Connect on the LDAP directory
   * @return bool
   */
  private static function connect() {
    if (is_a(self :: $connection, 'Net_LDAP2')) return true;
    Log :: debug(
      'Connect on LDAP host "%s" as %s (base DN="%s")',
      self :: $ldap_config['host'],
      isset(self :: $ldap_config['binddn'])?self :: $ldap_config['binddn']:"anonymous",
       self :: $ldap_config['basedn']
    );

    // @phpstan-ignore-next-line
    self :: $connection = Net_LDAP2::connect(self :: $ldap_config);
    // @phpstan-ignore-next-line
    if (PEAR::isError(self :: $connection)) {
      Log :: error(
        'Could not connect to LDAP server (%s): %s',
        self :: $ldap_config['host'], self :: $connection->getMessage());
      self :: $connection = null;
      return false;
    }
    return true;
  }

  /**
   * Make a search in the LDAP directory
   * @param string $filter The LDAP filter string
   * @param array|null $attrs Expected attributes (optional, default: all existing attributes)
   * @param string|null $basedn The base DN of the search (optional, default: configured root base
   *                            DN of the LDAP connection)
   * @param string|array<string>|null $sorted If defined, sort return objects by specified attribute(s)
   * @param array|null $options Search options as expected by NetLDAP::search() (optional, default: null)
   */
  public static function search($filter, $attrs=null, $basedn=null, $sorted=null, $options=null) {
    if (!self :: connect()) return false;
    $options = is_array($options)?$options:array();
    if (!is_null($attrs))
      $options['attributes'] = $attrs;

    Log :: debug(
      'Run search in LDAP directory with filter "%s" on base DN "%s"',
      $filter, $basedn?$basedn:"unset");
    $search = self :: $connection -> search($basedn, $filter, $options);

    // @phpstan-ignore-next-line
    if (PEAR::isError($search)) {
      Log :: error(
        'Error occured searching in LDAP with filter "%s" on base DN "%s": %s',
        $filter, $basedn?$basedn:"unset", $search->getMessage()
      );
      return false;
    }

    $entries = (
      $sorted?
      $search -> sorted(ensure_is_array($sorted)):
      $search -> entries()
    );

    $result = array();
    foreach ($entries as $entry)
      $result[$entry->dn()] = $entry -> getValues();

    return $result;
  }

  /**
   * Cast an LDAP value
   * @param mixed $value The raw LDAP value
   * @param string $type The expected type: see cast() for supported types, but boolean value will
   *                     be casted as LDAP boolean string.
   * @return mixed The casted value
   */
  public static function cast($value, $type) {
    switch($type) {
      case 'bool':
      case 'boolean':
        return $value == 'TRUE';
      case 'array_of_bool':
      case 'array_of_boolean':
        $values = array();
        foreach(ensure_is_array($value) as $value)
          $values[] = $value == 'TRUE';
        return $values;
      default:
        return cast($value, $type);
    }
  }

  /**
   * Retreive LDAP attribute value(s) from LDAP entry
   * @param array<string,mixed> $entry The LDAP entry
   * @param string $attr The LDAP attribute name
   * @param bool $all_values Return all values or just the first one (optional, default: false)
   * @param mixed $default The default value to return if the LDAP attribute is undefined
   *                       (optional, default: an empty array if $all_values, null otherwise)
   * @param string|null $cast The expected type of value (optional, default: string)
   */
  public static function get_attr($entry, $attr, $all_values=False, $default=null, $cast=null) {
    $values = self :: cast(
      isset($entry[$attr])?ensure_is_array($entry[$attr]):array(),
      "array_of_".($cast?$cast:'string')
    );
    if ($values)
      return $all_values?$values:$values[0];
    if ($all_values)
      return !is_null($default)?$default:array();
    return $default;
  }

  /**
   * Retreive a user by its username
   * @param string $username
   * @return \EesyPHP\Auth\User|null|false The user object if found, null it not, false in case of error
   */
  public static function get_user($username) {
    $attrs = App::get('auth.ldap.user_attributes', null, 'array');
    $attrs_names = array();
    foreach($attrs as $attr => $attr_config) {
      $name = Config::get("ldap_name", $attr, 'string', false, $attr_config);
      $alt_name = Config::get("alt_ldap_name", null, 'string', false, $attr_config);
      if (!in_array($name, $attrs_names))
        $attrs_names[] = $name;
      if ($alt_name && !in_array($alt_name, $attrs_names))
        $attrs_names[] = $alt_name;
    }
    $users = self :: search(
      str_replace(
        '[username]', Net_LDAP2_Filter::escape($username),
        App::get('auth.ldap.user_filter_by_uid', null, 'string')
      ),
      $attrs_names,
      App::get('auth.ldap.user_basedn', null, 'string')
    );
    if (!is_array($users)) {
      Log::warning('An error occured looking for user "%s" in LDAP directory', $username);
      return false;
    }
    if (!$users) {
      Log::debug('User "%s" not found in LDAP directory', $username);
      return null;
    }
    if (count($users) > 1) {
      Log::warning(
        'More than on users found with username "%s": %s',
        $username, implode(' / ', array_keys($users))
      );
    }
    $dn = key($users);
    $info = array('dn' => $dn);
    foreach($attrs as $name => $attr_config) {
      $ldap_name = Config::get("ldap_name", null, 'string', false, $attr_config);
      $alt_ldap_name = Config::get("alt_ldap_name", $name, 'string', false, $attr_config);
      if (!$ldap_name || is_null(self :: get_attr($users[$dn], $ldap_name)))
        $ldap_name = $alt_ldap_name;
      $info[$name] = self :: get_attr(
        $users[$dn],
        $ldap_name,
        Config::get("multivalued", false, 'bool', false, $attr_config),
        Config::get("default", null, null, false, $attr_config)
      );
    }
    Log::debug('User "%s" found in LDAP directory (%s):\n%s', $username, $dn, vardump($info));
    return new User($username, '\\EesyPHP\\Auth\\Ldap', $info);
  }

  /**
   * Check a user password
   * @param \EesyPHP\Auth\User $user The user object
   * @param string $password The password to check
   * @return boolean
   */
  public static function check_password($user, $password) {
    $config = self :: $ldap_config;
    $config['binddn'] = (
      App::get('auth.ldap.bind_with_username', null, 'bool')?
      $user->username:
      $user->dn
    );
    $config['bindpw'] = $password;
    $result = Net_LDAP2::connect($config);
    // @phpstan-ignore-next-line
    return !PEAR::isError($result);
  }

}
