<?php

namespace LAM\SCHEMA;
/*

  Copyright (C) 2004 David Smith
  modified to fit for LDAP Account Manager 2005 - 2026 Roland Gruber

  This program is free software; you can redistribute it and/or modify
  it under the terms of the GNU General Public License as published by
  the Free Software Foundation; either version 2 of the License, or
  (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

*/

/**
 * Classes and functions for fetching and parsing schema from an LDAP server.
 *
 * @package lib
 *
 * @author The phpLDAPadmin development team
 * @author Roland Gruber
 */

/**
 * Generic parent class for all schema items. A schema item is
 * an ObjectClass, an AttributeBype, a MatchingRule, or a Syntax.
 * All schema items have at least two things in common: An OID
 * and a description. This class provides an implementation for
 * these two data.
 *
 * @package lib
 */
class SchemaItem {
	/** @var string|null $oid The OID of this schema item. */
	protected $oid;
	/** @var string|null $description The description of this schema item. */
	protected $description;

	/** Initialize class members to default values. */
	function initVars(): void {
		$this->oid = null;
		$this->description = null;
	}

	/** Default constructor. */
	function __construct() {
		$this->initVars();
	}

	/**
	 * @param string|null $new_oid oid
	 */
	function setOID($new_oid): void {
		$this->oid = $new_oid;
	}

	/**
	 * @param string|null $new_desc description
	 */
	function setDescription($new_desc): void {
		$this->description = $new_desc;
	}

	/**
	 * @return string|null oid
	 */
	function getOID() {
		return $this->oid;
	}

	/**
	 * @return string|null description
	 */
	function getDescription() {
		return $this->description;
	}
}

/**
 * Represents an LDAP objectClass
 *
 * @package lib
 */
class ObjectClass extends SchemaItem {
	/** @var string|null $name This objectClass' name, ie "inetOrgPerson" */
	protected $name;
	/** @var string[] $sup_classes array of objectClass names from which this objectClass inherits */
	protected $sup_classes;
	/** @var string|null $type one of STRUCTURAL, ABSTRACT, or AUXILIARY */
	protected $type;
	/** @var string[] $must_attrs arrays of attribute names that this objectClass requires */
	protected $must_attrs;
	/** @var string[] $may_attrs arrays of attribute names that this objectClass allows, but does not require */
	protected $may_attrs;
	/** @var bool $is_obsolete boolean value indicating whether this objectClass is obsolete */
	protected $is_obsolete;
	/** @var string[] $children_objectclasses array of objectClasses which inherit from this one (must be set at runtime explicitly by the caller) */
	protected array $children_objectclasses = [];

	/** Initialize the class' member variables */
	function initVars(): void {
		parent::initVars();
		$this->oid = null;
		$this->name = null;
		$this->description = null;
		$this->sup_classes = [];
		$this->type = null;
		$this->must_attrs = [];
		$this->may_attrs = [];
		$this->is_obsolete = false;
		$this->children_objectclasses = [];
	}

	/**
	 * Creates a new ObjectClass object given a raw LDAP objectClass string.
	 *
	 * @param string $raw_ldap_schema_string schema string
	 */
	function __construct($raw_ldap_schema_string) {
		$this->initVars();
		$class = $raw_ldap_schema_string;
		$class = preg_replace('/\\(([a-z])/i', '( $1', $class);
		$class = preg_replace('/([a-z])\\)/i', '$1 )', $class);
		$strings = preg_split('/[\s,]+/', $class, -1, PREG_SPLIT_DELIM_CAPTURE);
		for ($i = 0; $i < count($strings); $i++) {
			switch ($strings[$i]) {
				case '(':
					break;
				case 'NAME':
					if ($strings[$i + 1] !== "(") {
						do {
							$i++;
							if (($this->name === null) || ((string) $this->name === '')) {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!str_ends_with($strings[$i], '\''));
					}
					else {
						$i++;
						do {
							$i++;
							if (($this->name === null) || ((string) $this->name === '')) {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!str_ends_with($strings[$i], '\''));
						do {
							$i++;
						}
						while (!preg_match('/\)+\)?/', $strings[$i]));
					}
					$this->name = preg_replace('/^\'/', "", $this->name);
					$this->name = preg_replace('/\'$/', "", $this->name);
					break;
				case 'DESC':
					do {
						$i++;
						if (($this->description === null) || ((string) $this->description === '')) {
							$this->description = $strings[$i];
						}
						else {
							$this->description = $this->description . " " . $strings[$i];
						}
					}
					while (!str_ends_with($strings[$i], '\''));
					break;
				case 'OBSOLETE':
					$this->is_obsolete = TRUE;
					break;
				case 'SUP':
					if ($strings[$i + 1] !== "(") {
						$i++;
						$this->sup_classes[] = preg_replace("/'/", "", $strings[$i]);
					}
					else {
						$i++;
						do {
							$i++;
							if ($strings[$i] !== "$") {
								$this->sup_classes[] = preg_replace("/'/", "", $strings[$i]);
							}
						}
						while (!preg_match('/\)+\)?/', $strings[$i + 1]));
					}
					break;
				case 'ABSTRACT':
					$this->type = 'abstract';
					break;
				case 'STRUCTURAL':
					$this->type = 'structural';
					break;
				case 'AUXILIARY':
					$this->type = 'auxiliary';
					break;
				case 'MUST':
					if (preg_match('/^\(./', $strings[$i + 1])) {
						$i++;
						$attr = new ObjectClassAttribute(preg_replace('/^\(/', "", $strings[$i]), $this->name);
						$this->must_attrs[] = $attr;
						do {
							$i++;
							if ($strings[$i] !== "$") {
								$attr = new ObjectClassAttribute($strings[$i], $this->name);
								$this->must_attrs[] = $attr;
							}
						}
						while (!preg_match('/\)+\)?/', $strings[$i + 1]));
					}
					elseif ($strings[$i + 1] !== "(") {
						$i++;
						$attr = new ObjectClassAttribute($strings[$i], $this->name);
						$this->must_attrs[] = $attr;
					}
					else {
						$i++;
						do {
							$i++;
							if ($strings[$i] !== "$") {
								$attr = new ObjectClassAttribute($strings[$i], $this->name);
								$this->must_attrs[] = $attr;
							}
						}
						while (!preg_match('/\)+\)?/', $strings[$i + 1]));
					}
					sort($this->must_attrs);
					break;
				case 'MAY':
					if (preg_match('/^\(./', $strings[$i + 1])) {
						$i++;
						$attr = new ObjectClassAttribute(preg_replace('/^\(/', "", $strings[$i]), $this->name);
						$this->may_attrs[] = $attr;
						do {
							$i++;
							if ($strings[$i] !== "$") {
								$attr = new ObjectClassAttribute($strings[$i], $this->name);
								$this->may_attrs[] = $attr;
							}
						}
						while (!preg_match('/\)+\)?/', $strings[$i + 1]));
					}
					elseif ($strings[$i + 1] !== "(") {
						$i++;
						$attr = new ObjectClassAttribute($strings[$i], $this->name);
						$this->may_attrs[] = $attr;
					}
					else {
						$i++;
						do {
							$i++;
							if ($strings[$i] !== "$") {
								$attr = new ObjectClassAttribute($strings[$i], $this->name);
								$this->may_attrs[] = $attr;
							}
						}
						while (!preg_match('/\)+\)?/', $strings[$i + 1]));
					}
					sort($this->may_attrs);
					break;
				default:
					if (preg_match('/[\d\.]+/i', $strings[$i]) && $i === 1) {
						$this->oid = $strings[$i];
					}
			}
		}

		if ($this->description !== null) {
			$this->description = preg_replace('/^\'/', "", $this->description);
			$this->description = preg_replace('/\'$/', "", $this->description);
		}
	}

	/**
	 * Gets an array of AttributeType objects that entries of this ObjectClass must define.
	 * This differs from getMustAttrNames in that it returns an array of AttributeType objects
	 *
	 * @param ObjectClass[] $oclasses An array of ObjectClass objects to use when traversing
	 *             the inheritance tree. This presents some what of a bootstrapping problem
	 *             as we must fetch all objectClasses to determine through inheritance which
	 *             attributes this objectClass requires.
	 * @return AttributeType[] attributes
	 *
	 * @see getMustAttrNames
	 * @see getMayAttrs
	 * @see getMayAttrNames
	 */
	function getMustAttrs($oclasses = NULL) {
		$all_must_attrs = $this->must_attrs;
		foreach ($this->sup_classes as $sup_class) {
			if ($oclasses != null
				&& $sup_class != "top"
				&& isset($oclasses[strtolower($sup_class)])) {
				$sup_class = $oclasses[strtolower($sup_class)];
				$sup_class_must_attrs = $sup_class->getMustAttrs($oclasses);
				$all_must_attrs = array_merge($sup_class_must_attrs, $all_must_attrs);
			}
		}

		ksort($all_must_attrs);
		return $all_must_attrs;
	}

	/**
	 * Gets an array of AttributeType objects that entries of this ObjectClass may define.
	 * This differs from getMayAttrNames in that it returns an array of AttributeType objects
	 *
	 * @param ObjectClass[] $oclasses An array of ObjectClass objects to use when traversing
	 *             the inheritance tree. This presents some what of a bootstrapping problem
	 *             as we must fetch all objectClasses to determine through inheritance which
	 *             attributes this objectClass provides.
	 * @return AttributeType[] attributes
	 *
	 * @see getMustAttrNames
	 * @see getMustAttrs
	 * @see getMayAttrNames
	 * @see AttributeType
	 */
	function getMayAttrs($oclasses = NULL) {
		$all_may_attrs = $this->may_attrs;
		foreach ($this->sup_classes as $sup_class_name) {
			if ($oclasses != null
				&& $sup_class_name != "top"
				&& isset($oclasses[strtolower($sup_class_name)])) {
				$sup_class = $oclasses[strtolower($sup_class_name)];
				$sup_class_may_attrs = $sup_class->getMayAttrs($oclasses);
				$all_may_attrs = array_merge($sup_class_may_attrs, $all_may_attrs);
			}
		}

		ksort($all_may_attrs);
		return $all_may_attrs;
	}

	/**
	 * Gets an array of attribute names (strings) that entries of this ObjectClass must define.
	 * This differs from getMustAttrs in that it returns an array of strings rather than
	 * array of AttributeType objects
	 *
	 * @param ObjectClass[] $oclasses An array of ObjectClass objects to use when traversing
	 *             the inheritance tree. This presents some what of a bootstrapping problem
	 *             as we must fetch all objectClasses to determine through inheritance which
	 *             attributes this objectClass provides.
	 * @return string[] The array of allowed attribute names (strings).
	 *
	 * @see getMustAttrs
	 * @see getMayAttrs
	 * @see getMayAttrNames
	 */
	function getMustAttrNames($oclasses = null): array {
		$attrs = $this->getMustAttrs($oclasses);
		$attr_names = [];
		foreach ($attrs as $attr)
			$attr_names[] = $attr->getName();
		return $attr_names;
	}

	/**
	 * Gets an array of attribute names (strings) that entries of this ObjectClass must define.
	 * This differs from getMayAttrs in that it returns an array of strings rather than
	 * array of AttributeType objects
	 *
	 * @param ObjectClass[] $oclasses An array of ObjectClass objects to use when traversing
	 *             the inheritance tree. This presents some what of a bootstrapping problem
	 *             as we must fetch all objectClasses to determine through inheritance which
	 *             attributes this objectClass provides.
	 * @return string[] The array of allowed attribute names (strings).
	 *
	 * @see getMustAttrs
	 * @see getMayAttrs
	 * @see getMustAttrNames
	 */
	function getMayAttrNames($oclasses = null): array {
		$attrs = $this->getMayAttrs($oclasses);
		$attr_names = [];
		foreach ($attrs as $attr)
			$attr_names[] = $attr->getName();
		return $attr_names;
	}

	/**
	 * Adds an objectClass to the list of objectClasses that inherit
	 * from this objectClass.
	 * @param string $object_class_name The name of the objectClass to add
	 * @return bool Returns true on success or false on failure (objectclass already existed for example)
	 */
	function addChildObjectClass($object_class_name) {
		$object_class_name = trim($object_class_name);
		foreach ($this->children_objectclasses as $existing_objectclass)
			if (0 === strcasecmp($object_class_name, $existing_objectclass)) {
				return false;
			}
		$this->children_objectclasses[] = $object_class_name;
		return true;
	}

	/**
	 * Returns the array of objectClass names which inherit from this objectClass.
	 * @return string[] Names of objectClasses which inherit from this objectClass.
	 */
	function getChildObjectClasses() {
		return $this->children_objectclasses;
	}

	/**
	 * Gets the name of this objectClass (ie, "inetOrgPerson")
	 * @return string The name of the objectClass
	 */
	function getName() {
		return $this->name;
	}

	/**
	 * Gets the objectClass names from which this objectClass inherits.
	 *
	 * @return string[] objectClass names
	 */
	function getSupClasses(): array {
		return $this->sup_classes;
	}

	/**
	 * Gets the type of this objectClass: STRUCTURAL, ABSTRACT, or AUXILIARY.
	 *
	 * @return string type
	 */
	function getType() {
		return $this->type;
	}

	/**
	 * Gets whether this objectClass is flagged as obsolete by the LDAP server.
	 *
	 * @return bool obsolete
	 */
	function getIsObsolete() {
		return $this->is_obsolete;
	}

}

/**
 * A simple class for representing AttributeTypes used only by the ObjectClass class.
 * Users should never instantiate this class. It represents an attribute internal to
 * an ObjectClass. If PHP supported inner-classes and variable permissions, this would
 * be interior to class ObjectClass and flagged private. The reason this class is used
 * and not the "real" class AttributeType is because this class supports the notion of
 * a "source" objectClass, meaning that it keeps track of which objectClass originally
 * specified it. This class is therefore used by the class ObjectClass to determine
 * inheritance.
 *
 * @package lib
 */
class ObjectClassAttribute {
	/** @var string $name This Attribute's name */
	protected $name;
	/** @var string $source This Attribute's root */
	protected $source;

	/**
	 * Creates a new ObjectClassAttribute with specified name and source objectClass.
	 * @param string $name the name of the new attribute.
	 * @param string $source the name of the ObjectClass which
	 *           specifies this attribute.
	 */
	function __construct($name, $source) {
		$this->name = $name;
		$this->source = $source;
	}

	/**
	 * Gets this attribute's name
	 *
	 * @return string name
	 */
	function getName() {
		return $this->name;
	}

	/**
	 * Gets the name of the ObjectClass which originally specified this attribute.
	 * @return string source
	 */
	function getSource() {
		return $this->source;
	}
}


/**
 * Represents an LDAP AttributeType
 *
 * @package lib
 */
class AttributeType extends SchemaItem {
	/** @var string|null $name The name of this attributeType */
	protected $name;
	/** @var bool $is_obsolete obsolete */
	protected $is_obsolete;
	/** @var string|null $sup_attribute The attribute from which this attribute inherits (if any) */
	protected $sup_attribute;
	/** @var string|null $equality The equality rule used */
	protected $equality;
	/** @var string|null $ordering The ordering of the attributeType */
	protected $ordering;
	/** @var string|null $sub_str substring matching */
	protected $sub_str;
	/** @var string|null $syntax The full syntax string, ie 1.2.3.4{16} */
	protected $syntax;
	/** @var string|null $syntax_oid syntax OID */
	protected $syntax_oid;
	/** @var bool|null $is_single_value boolean: is single valued only? */
	protected $is_single_value;
	/** @var bool $is_collective boolean: is collective? */
	protected $is_collective;
	/** @var bool $is_no_user_modification boolean: can use modify? */
	protected $is_no_user_modification;
	/** @var string|null $usage The usage string set by the LDAP schema */
	protected $usage;
	/** @var string[] $aliases An array of alias attribute names, strings */
	protected $aliases;
	/** @var string|null $max_length The max number of characters this attribute can be */
	protected $max_length;
	/** @var string|null $type A string description of the syntax type (taken from the LDAPSyntaxes) */
	protected $type;
	/** @var string[] $used_in_object_classes An array of objectClasses which use this attributeType (must be set by caller) */
	protected $used_in_object_classes;
	/** @var string[] $required_by_object_classes A list of object class names that require this attribute type.  */
	protected $required_by_object_classes = [];

	/**
	 * Initialize the class' member variables
	 */
	function initVars(): void {
		parent::initVars();
		$this->oid = null;
		$this->name = null;
		$this->description = null;
		$this->is_obsolete = false;
		$this->sup_attribute = null;
		$this->equality = null;
		$this->ordering = null;
		$this->sub_str = null;
		$this->syntax_oid = null;
		$this->syntax = null;
		$this->max_length = null;
		$this->is_single_value = null;
		$this->is_collective = false;
		$this->is_no_user_modification = false;
		$this->usage = null;
		$this->aliases = [];
		$this->type = null;
		$this->used_in_object_classes = [];
		$this->required_by_object_classes = [];
	}

	/**
	 * Creates a new AttributeType objcet from a raw LDAP AttributeType string.
	 * @param string $raw_ldap_attr_string attribute string
	 */
	function __construct($raw_ldap_attr_string) {
		$this->initVars();
		$attr = $raw_ldap_attr_string;
		$strings = preg_split("/[\s,]+/", $attr, -1, PREG_SPLIT_DELIM_CAPTURE);
		for ($i = 0; $i < count($strings); $i++) {
			switch ($strings[$i]) {
				case '(':
					break;
				case 'NAME':
					if ($strings[$i + 1] !== "(") {
						do {
							$i++;
							if (($this->name === null) || ((string) $this->name === '')) {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!preg_match("/\'$/", $strings[$i]));
						// this attribute has no aliases
						$this->aliases = [];
					}
					else {
						$i++;
						do {
							$i++;
							if (empty($this->name)) {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!preg_match("/\'$/", $strings[$i]));
						// add alias names for this attribute
						while ($strings[++$i] !== ")") {
							$alias = $strings[$i];
							$alias = preg_replace("/^\'/", "", $alias);
							$alias = preg_replace("/\'$/", "", $alias);
							$this->aliases[] = $alias;
						}
					}
					break;
				case 'DESC':
					do {
						$i++;
						if (empty($this->description)) {
							$this->description .= $strings[$i];
						}
						else {
							$this->description = $this->description . " " . $strings[$i];
						}
					}
					while (!preg_match("/\'$/", $strings[$i]));
					break;
				case 'OBSOLETE':
					$this->is_obsolete = TRUE;
					break;
				case 'SUP':
					$i++;
					$this->sup_attribute = $strings[$i];
					break;
				case 'EQUALITY':
					$i++;
					$this->equality = $strings[$i];
					break;
				case 'ORDERING':
					$i++;
					$this->ordering = $strings[$i];
					break;
				case 'SUBSTR':
					$i++;
					$this->sub_str = $strings[$i];
					break;
				case 'SYNTAX':
					$i++;
					$this->syntax = $strings[$i];
					$this->syntax_oid = preg_replace("/{\d+}$/", "", $this->syntax);
					// does this SYNTAX string specify a max length (ie, 1.2.3.4{16})
					$matches = [];
					$this->max_length = preg_match("/{(\d+)}$/", $this->syntax, $matches) ? $matches[1] : null;
					if ($i < count($strings) - 1 && $strings[$i + 1] === "{") {
						do {
							$i++;
							$this->name .= " " . $strings[$i];
						}
						while ($strings[$i] !== "}");
					}
					break;
				case 'SINGLE-VALUE':
					$this->is_single_value = TRUE;
					break;
				case 'COLLECTIVE':
					$this->is_collective = TRUE;
					break;
				case 'NO-USER-MODIFICATION':
					$this->is_no_user_modification = TRUE;
					break;
				case 'USAGE':
					$i++;
					$this->usage = $strings[$i];
					break;
				default:
					if (preg_match("/[\d\.]+/i", $strings[$i]) && $i === 1) {
						$this->oid = $strings[$i];
					}
			}
		}

		if ($this->name !== null) {
			$this->name = preg_replace("/^\'/", "", $this->name);
			$this->name = preg_replace("/\'$/", "", $this->name);
		}
		if ($this->description !== null) {
			$this->description = preg_replace("/^\'/", "", $this->description);
			$this->description = preg_replace("/\'$/", "", $this->description);
		}
		if (!empty($this->syntax)) {
			$this->syntax = preg_replace("/^\'/", "", $this->syntax);
			$this->syntax = preg_replace("/\'$/", "", $this->syntax);
		}
		if (!empty($this->syntax_oid)) {
			$this->syntax_oid = preg_replace("/^\'/", "", $this->syntax_oid);
			$this->syntax_oid = preg_replace("/\'$/", "", $this->syntax_oid);
		}
		if ($this->sup_attribute !== null) {
			$this->sup_attribute = preg_replace("/^\'/", "", $this->sup_attribute);
			$this->sup_attribute = preg_replace("/\'$/", "", $this->sup_attribute);
		}
	}

	/**
	 * Gets this attribute's name
	 * @return string
	 */
	function getName() {
		return $this->name;
	}

	/**
	 * Gets whether this attribute has been flagged as obsolete by the LDAP server
	 * @return bool
	 */
	function getIsObsolete() {
		return $this->is_obsolete;
	}

	/**
	 * Gets this attribute's usage string as defined by the LDAP server
	 * @return string
	 */
	function getUsage() {
		return $this->usage;
	}

	/**
	 * Gets this attribute's parent attribute (if any). If this attribute does not
	 * inherit from another attribute, null is returned.
	 * @return string|null parent attribute name
	 */
	function getSupAttribute(): ?string {
		return $this->sup_attribute;
	}

	/**
	 * Gets this attribute's equality string
	 * @return string
	 */
	function getEquality() {
		return $this->equality;
	}

	/**
	 * Gets this attribute's ordering specification.
	 * @return string
	 */
	function getOrdering() {
		return $this->ordering;
	}

	/**
	 * Gets this attribute's substring matching specification
	 * @return string
	 */
	function getSubstr() {
		return $this->sub_str;
	}

	/**
	 * Gets the names of attributes that are an alias for this attribute (if any).
	 * @return string[] An array of names of attributes which alias this attribute or
	 *          an empty array if no attribute aliases this object.
	 */
	function getAliases(): array {
		return $this->aliases;
	}

	/**
	 * Returns whether the specified attribute is an alias for this one (based on this attribute's alias list).
	 * @param string $attr_name The name of the attribute to check.
	 * @return bool True if the specified attribute is an alias for this one, or false otherwise.
	 */
	function isAliasFor($attr_name) {
		foreach ($this->aliases as $alias_attr_name)
			if (0 === strcasecmp($alias_attr_name, $attr_name)) {
				return true;
			}
		return false;

	}

	/**
	 * Gets this attribute's raw syntax string (ie: "1.2.3.4{16}").
	 * @return string The raw syntax string
	 */
	function getSyntaxString() {
		return $this->syntax;
	}

	/**
	 * Gets this attribute's syntax OID. Differs from getSyntaxString() in that this
	 * function only returns the actual OID with any length specification removed.
	 * Ie, if the syntax string is "1.2.3.4{16}", this function only returns
	 * "1.2.3.4".
	 * @return string The syntax OID string.
	 */
	function getSyntaxOID() {
		return $this->syntax_oid;
	}

	/**
	 * Gets this attribute's the maximum length. If no maximum is defined by the LDAP server, null is returned.
	 * @return string|null The maximum length (in characters) of this attribute or null if no maximum is specified.
	 */
	function getMaxLength() {
		return $this->max_length;
	}

	/**
	 * Gets whether this attribute is single-valued. If this attribute only supports single values, true
	 * is returned. If this attribute supports multiple values, false is returned.
	 * @return bool Returns true if this attribute is single-valued or false otherwise.
	 */
	function getIsSingleValue() {
		return $this->is_single_value;
	}

	/**
	 * Sets whether this attribute is single-valued.
	 * @param bool $is_single_value
	 */
	function setIsSingleValue($is_single_value): void {
		$this->is_single_value = $is_single_value;
	}

	/**
	 * Gets whether this attribute is collective.
	 * @return bool Returns true if this attribute is collective and false otherwise.
	 */
	function getIsCollective() {
		return $this->is_collective;
	}

	/**
	 * Gets whether this attribute is not modifiable by users.
	 * @return bool Returns true if this attribute is not modifiable by users.
	 */
	function getIsNoUserModification() {
		return $this->is_no_user_modification;
	}

	/**
	 * Gets this attribute's type
	 * @return string The attribute's type.
	 */
	function getType() {
		return $this->type;
	}

	/**
	 * Removes an attribute name from this attribute's alias array.
	 * @param string $remove_alias_name The name of the attribute to remove.
	 * @return bool true on success or false on failure (ie, if the specified
	 *           attribute name is not found in this attribute's list of aliases)
	 */
	function removeAlias($remove_alias_name) {
		foreach ($this->aliases as $i => $alias_name) {
			if (0 === strcasecmp($alias_name, $remove_alias_name)) {
				unset($this->aliases[$i]);
				$this->aliases = array_values($this->aliases);
				return true;
			}
		}
		return false;
	}

	/**
	 * Adds an attribute name to the alias array.
	 * @param string $new_alias_name The name of a new attribute to add to this attribute's list of aliases.
	 */
	function addAlias($new_alias_name): void {
		$this->aliases[] = $new_alias_name;
	}

	/**
	 * Sets this attriute's name.
	 * @param string $new_name The new name to give this attribute.
	 */
	function setName($new_name): void {
		$this->name = $new_name;
	}

	/**
	 * Sets this attriute's SUP attribute (ie, the attribute from which this attribute inherits).
	 * @param string $new_sup_attr The name of the new parent (SUP) attribute
	 */
	function setSupAttribute($new_sup_attr): void {
		$this->sup_attribute = $new_sup_attr;
	}

	/**
	 * Sets this attribute's list of aliases.
	 * @param string[] $new_aliases The array of alias names (strings)
	 */
	function setAliases($new_aliases): void {
		$this->aliases = $new_aliases;
	}

	/**
	 * Sets this attribute's type.
	 * @param string $new_type The new type.
	 */
	function setType($new_type): void {
		$this->type = $new_type;
	}

	/**
	 * Adds an objectClass name to this attribute's list of "used in" objectClasses,
	 * that is the list of objectClasses which provide this attribute.
	 * @param string $object_class_name The name of the objectClass to add.
	 * @return bool false if already added
	 */
	function addUsedInObjectClass($object_class_name) {
		foreach ($this->used_in_object_classes as $used_in_object_class)
			if (0 === strcasecmp($used_in_object_class, $object_class_name)) {
				return false;
			}
		$this->used_in_object_classes[] = $object_class_name;
		return true;
	}

	/**
	 * Gets the list of "used in" objectClasses, that is the list of objectClasses
	 * which provide this attribute.
	 * @return string[] An array of names of objectclasses (strings) which provide this attribute
	 */
	function getUsedInObjectClasses(): array {
		return $this->used_in_object_classes;
	}

	/**
	 * Adds an objectClass name to this attribute's list of "required by" objectClasses,
	 * that is the list of objectClasses which must have this attribute.
	 * @param string $object_class_name The name of the objectClass to add.
	 * @return bool false if already added
	 */
	function addRequiredByObjectClass($object_class_name) {
		foreach ($this->required_by_object_classes as $required_by_object_class)
			if (0 === strcasecmp($required_by_object_class, $object_class_name)) {
				return false;
			}
		$this->required_by_object_classes[] = $object_class_name;
		return true;
	}

	/**
	 * Gets the list of "required by" objectClasses, that is the list of objectClasses
	 * which provide must have attribute.
	 * @return string[] An array of names of objectclasses (strings) which provide this attribute
	 */
	function getRequiredByObjectClasses() {
		return $this->required_by_object_classes;
	}

	/**
	 * Returns if this attribute is binary.
	 *
	 * @return bool is binary
	 */
	public function isBinary(): bool {
		return strcasecmp($this->type, 'Certificate') === 0 ||
			strcasecmp($this->type, 'Binary') === 0 ||
			strcasecmp($this->name, 'usercertificate') === 0 ||
			strcasecmp($this->name, 'usersmimecertificate') === 0 ||
			strcasecmp($this->name, 'networkaddress') === 0 ||
			strcasecmp($this->name, 'objectGUID') === 0 ||
			strcasecmp($this->name, 'objectSID') === 0 ||
			strcasecmp($this->name, 'auditingPolicy') === 0 ||
			strcasecmp($this->name, 'jpegPhoto') === 0 ||
			strcasecmp($this->name, 'krbExtraData') === 0 ||
			strcasecmp($this->name, 'krbPrincipalKey') === 0 ||
			$this->syntax_oid === '1.3.6.1.4.1.1466.115.121.1.10' ||
			$this->syntax_oid === '1.3.6.1.4.1.1466.115.121.1.28' ||
			$this->syntax_oid === '1.3.6.1.4.1.1466.115.121.1.5' ||
			$this->syntax_oid === '1.3.6.1.4.1.1466.115.121.1.8' ||
			$this->syntax_oid === '1.3.6.1.4.1.1466.115.121.1.9';
	}

}

/**
 * Represents an LDAP Syntax
 *
 * @package lib
 */
class Syntax extends SchemaItem {
	/** Initializes the class' member variables */
	function initVars(): void {
		parent::initVars();
		$this->oid = null;
		$this->description = null;
	}

	/**
	 * Creates a new Syntax object from a raw LDAP syntax string.
	 *
	 * @param string $raw_ldap_syntax_string syntax string
	 */
	function __construct($raw_ldap_syntax_string) {
		$this->initVars();
		$class = $raw_ldap_syntax_string;
		$strings = preg_split("/[\s,]+/", $class, -1, PREG_SPLIT_DELIM_CAPTURE);
		for ($i = 0; $i < count($strings); $i++) {
			switch ($strings[$i]) {
				case '(':
					break;
				case 'DESC':
					do {
						$i++;
						if (empty($this->description)) {
							$this->description .= $strings[$i];
						}
						else {
							$this->description = $this->description . " " . $strings[$i];
						}
					}
					while (!preg_match("/\'$/", $strings[$i]));
					break;
				default:
					if (preg_match("/[\d\.]+/i", $strings[$i]) && $i === 1) {
						$this->oid = $strings[$i];
					}
			}
		}
		$this->description = preg_replace("/^\'/", "", $this->description);
		$this->description = preg_replace("/\'$/", "", $this->description);
	}
}

/**
 * Represents an LDAP MatchingRule
 *
 * @package lib
 */
class MatchingRule extends SchemaItem {
	/** @var string|null $name This rule's name */
	protected $name;
	/** @var string|null $syntax This rule's syntax OID */
	protected $syntax;
	/** @var bool $is_obsolete Boolean value indicating whether this MatchingRule is obsolete */
	protected $is_obsolete;
	/** @var string[] $used_by_attrs An array of attribute names who use this MatchingRule */
	protected $used_by_attrs;

	/** Initialize the class' member variables */
	function initVars(): void {
		parent::initVars();
		$this->oid = null;
		$this->name = null;
		$this->description = null;
		$this->is_obsolete = false;
		$this->syntax = null;
		$this->used_by_attrs = [];
	}

	/**
	 * Creates a new MatchingRule object from a raw LDAP MatchingRule string.
	 *
	 * @param string $raw_ldap_matching_rule_string rule string
	 */
	function __construct($raw_ldap_matching_rule_string) {
		$this->initVars();
		$strings = preg_split("/[\s,]+/", $raw_ldap_matching_rule_string, -1, PREG_SPLIT_DELIM_CAPTURE);
		for ($i = 0; $i < count($strings); $i++) {
			switch ($strings[$i]) {
				case '(':
					break;
				case 'NAME':
					if ($strings[$i + 1] !== "(") {
						do {
							$i++;
							if (empty($this->name)) {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!preg_match("/\'$/", $strings[$i]));
					}
					else {
						$i++;
						do {
							$i++;
							if ((string) $this->name === '') {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!preg_match("/\'$/", $strings[$i]));
						do {
							$i++;
						}
						while (!preg_match('/\)+\)?/', $strings[$i]));
					}
					$this->name = preg_replace("/^\'/", "", $this->name);
					$this->name = preg_replace("/\'$/", "", $this->name);
					break;
				case 'DESC':
					do {
						$i++;
						if ((string) $this->description === '') {
							$this->description .= $strings[$i];
						}
						else {
							$this->description = $this->description . " " . $strings[$i];
						}
					}
					while (!preg_match("/\'$/", $strings[$i]));
					break;
				case 'OBSOLETE':
					$this->is_obsolete = TRUE;
					break;
				case 'SYNTAX':
					$this->syntax = $strings[++$i];
					break;
				default:
					if (preg_match("/[\d\.]+/i", $strings[$i]) && $i === 1) {
						$this->oid = $strings[$i];
					}
			}
		}
		if (!empty($this->description)) {
			$this->description = preg_replace("/^\'/", "", $this->description);
			$this->description = preg_replace("/\'$/", "", $this->description);
		}
	}

	/**
	 * Sets the list of used_by_attrs to the array specified by $attrs;
	 * @param string[] $attrs The array of attribute names (strings) which use this MatchingRule
	 */
	function setUsedByAttrs($attrs): void {
		$this->used_by_attrs = $attrs;
	}

	/**
	 * Adds an attribute name to the list of attributes who use this MatchingRule
	 *
	 * @param string $new_attr_name attribute name
	 * @return bool true if the attribute was added and false otherwise (already in the list)
	 */
	function addUsedByAttr($new_attr_name): bool {
		foreach ($this->used_by_attrs as $attr_name)
			if (0 === strcasecmp($attr_name, $new_attr_name)) {
				return false;
			}
		$this->used_by_attrs[] = $new_attr_name;
		return true;
	}

	/**
	 * Gets this MatchingRule's name.
	 * @return string The name.
	 */
	function getName() {
		return $this->name;
	}

	/**
	 * Gets whether this MatchingRule is flagged as obsolete by the LDAP server.
	 * @return bool True if this MatchingRule is obsolete and false otherwise.
	 */
	function getIsObsolete() {
		return $this->is_obsolete;
	}

	/**
	 * Gets an array of attribute names (strings) which use this MatchingRule
	 * @return string[] The array of attribute names (strings).
	 */
	function getUsedByAttrs() {
		return $this->used_by_attrs;
	}
}

/**
 * Represents an LDAP schema matchingRuleUse entry
 *
 * @package lib
 */
class MatchingRuleUse extends SchemaItem {
	/** @var string|null The name of the MathingRule this applies to */
	protected $name;
	/** @var string[] An array of attributeType names who make use of the mathingRule
	 * identified by $this->oid and $this->name */
	protected $used_by_attrs;

	/** Initialize the class' member variables */
	function initVars(): void {
		parent::initVars();
		$this->oid = null;
		$this->name = null;
		$this->used_by_attrs = [];
	}

	/**
	 * @param string $raw_matching_rule_use_string rule string
	 */
	function __construct($raw_matching_rule_use_string) {
		$this->initVars();
		$strings = preg_split("/[\s,]+/", $raw_matching_rule_use_string, -1, PREG_SPLIT_DELIM_CAPTURE);
		for ($i = 0; $i < count($strings); $i++) {
			switch ($strings[$i]) {
				case '(':
					break;
				case 'NAME':
					if ($strings[$i + 1] !== "(") {
						do {
							$i++;
							if ($this->name === null || (string) $this->name === '') {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!preg_match("/\'$/", $strings[$i]));
					}
					else {
						$i++;
						do {
							$i++;
							if ((string) $this->name === '') {
								$this->name = $strings[$i];
							}
							else {
								$this->name .= " " . $strings[$i];
							}
						}
						while (!preg_match("/\'$/", $strings[$i]));
						do {
							$i++;
						}
						while (!preg_match('/\)+\)?/', $strings[$i]));
					}
					$this->name = preg_replace("/^\'/", "", $this->name);
					$this->name = preg_replace("/\'$/", "", $this->name);
					break;
				case 'APPLIES':
					if ($strings[$i + 1] !== "(") {
						// has a single attribute name
						$i++;
						$this->used_by_attrs = [$strings[$i]];
						//echo "Adding single: " . $strings[$i] . "<br />";
					}
					else {
						// has multiple attribute names
						$i++;
						while ($strings[$i] !== ")") {
							$i++;
							$new_attr = $strings[$i];
							$new_attr = preg_replace("/^\'/", "", $new_attr);
							$new_attr = preg_replace("/\'$/", "", $new_attr);
							$this->used_by_attrs[] = $new_attr;
							//echo "Adding $new_attr<br />";
							$i++;
						}
					}
					break;
				default:
					if (preg_match("/[\d\.]+/i", $strings[$i]) && $i === 1) {
						$this->oid = $strings[$i];
					}
			}
		}
		sort($this->used_by_attrs);
	}

	/**
	 * Gets this MatchingRuleUse's name
	 * @return string The name
	 */
	function getName() {
		return $this->name;
	}

	/**
	 * Gets an array of attribute names (strings) which use this MatchingRuleUse object.
	 * @return string[] The array of attribute names (strings).
	 */
	function getUsedByAttrs() {
		return $this->used_by_attrs;
	}
}

/**
 * Helper for _get_raw_schema() which fetches the DN of the schema object
 * in an LDAP server based on a DN. Entries should set the subSchemaSubEntry
 * attribute pointing to the DN of the server schema. You can specify  the
 * DN whose subSchemaSubEntry you wish to retrieve of specify an empty string
 * to fetch the subScehamSubEntry from the Root DSE.
 *
 * @param string $dn the DN (may be null) which houses the subschemaSubEntry attribute which
 *                  this function can use to determine the schema entry's DN.
 * @param bool $debug switch to true to see some nice and copious output. :)
 *
 * @return string|false the DN of the entry which houses this LDAP server's schema.
 */
function _get_schema_dn($dn, $debug = false): bool|string {
	if ($debug) {
		echo "<pre>";
	}
	$ds = $_SESSION['ldap']->server();

	$search = @ldap_read($ds, $dn, '(objectClass=*)', ['subschemaSubentry']);
	if ($debug) {
		echo "Search result (ldap_read): ";
		var_dump($search);
		echo "\n";
	}
	if (!$search) {
		if ($debug) {
			echo "_get_schema_dn() returning false. (search val is false)\n";
		}
		return false;
	}

	if (@ldap_count_entries($ds, $search) === 0) {
		if ($debug) {
			echo "_get_schema_dn() returning false (ldap_count_entries() == 0).\n";
		}
		return false;
	}

	$entries = @ldap_get_entries($ds, $search);
	if ($debug) {
		echo "Entries (ldap_get_entries): ";
		var_dump($entries);
		echo "\n";
	}
	if ($entries === false) {
		if ($debug) {
			echo "_get_schema_dn() returning false (Bad entries val, false or not array).\n";
		}
		return false;
	}

	$entry = $entries[0] ?? false;
	if (!$entry) {
		if ($debug) {
			echo "_get_schema_dn() returning false (entry val is false)\n";
		}
		return false;
	}

	$sub_schema_sub_entry = $entry[0] ?? false;
	if (!$sub_schema_sub_entry) {
		if ($debug) {
			echo "_get_schema_dn() returning false (sub_schema_sub_entry val is false)\n";
		}
		return false;
	}

	$schema_dn = $entry[$sub_schema_sub_entry][0] ?? false;

	if ($debug) {
		echo "_get_schema_dn() returning: \"" . $schema_dn . "\"\n";
	}
	return $schema_dn;
}

/**
 * Fetches the raw schema array for the subschemaSubentry of the server. Note,
 * this function has grown many hairs to accommodate more LDAP servers. It is
 * needfully complicated as it now supports many popular LDAP servers that
 * don't necessarily expose their schema "the right way".
 *
 * @param string $schema_to_fetch - A string indicating which type of schema to
 *        fetch. Five valid values: 'objectclasses', 'attributetypes',
 *        'ldapsyntaxes', 'matchingruleuse', or 'matchingrules'.
 *        Case insensitive.
 * @param string $dn (optional) This parameter is the DN of the entry whose schema you
 *        would like to fetch. Entries have the option of specifying
 *        their own subschemaSubentry that points to the DN of the system
 *        schema entry which applies to this attribute. If unspecified,
 *        this will try to retrieve the schema from the RootDSE subschemaSubentry.
 *        Failing that, we use some commonly known schema DNs. Default
 *        value is the Root DSE DN (zero-length string)
 * @return string[]|false an array of strings of this form:
 *    Array (
 *      [0] => "( 1.3.6.1.4.1.7165.1.2.2.4 NAME 'gidPool' DESC 'Pool ...
 *      [1] => "( 1.3.6.1.4.1.7165.2.2.3 NAME 'sambaAccount' DESC 'Sa ...
 *      etc.
 */
function _get_raw_schema($schema_to_fetch, $dn = '') {

	$ds = $_SESSION['ldap']->server();

	// error checking
	$schema_to_fetch = strtolower($schema_to_fetch);

	// Try to get the schema DN from the specified entry.
	$schema_dn = _get_schema_dn($dn);

	// Do we need to try again with the Root DSE?
	if (!$schema_dn) {
		$schema_dn = _get_schema_dn('');
	}

	// Store the eventual schema retrieval in $schema_search
	$schema_search = null;

	if ($schema_dn) {
		$schema_search = @ldap_read($ds, $schema_dn, '(objectClass=*)',
			[$schema_to_fetch], 0, 0, 0,
			LDAP_DEREF_ALWAYS);

		// Were we not able to fetch the schema from the $schema_dn?
		$schema_entries = $schema_search !== false ? @ldap_get_entries($ds, $schema_search) : [];
		if ($schema_search === false ||
			0 === @ldap_count_entries($ds, $schema_search) ||
			!isset($schema_entries[0][$schema_to_fetch])) {
			// Try again with a different filter (some servers require (objectClass=subschema) like M-Vault)
			$schema_search = @ldap_read($ds, $schema_dn, '(objectClass=subschema)',
				[$schema_to_fetch], 0, 0, 0,
				LDAP_DEREF_ALWAYS);
			if ($schema_search !== false) {
				$schema_entries = @ldap_get_entries($ds, $schema_search);
			}
			// Still didn't get it?
			if ($schema_search === false ||
				0 === @ldap_count_entries($ds, $schema_search) ||
				!isset($schema_entries[0][$schema_to_fetch])) {
				unset($schema_entries);
				unset($schema_dn);
				$schema_search = null;
			}
		}
	}

	// Second chance: If the DN or Root DSE didn't give us the subschemaSubentry, ie $schema_search
	// is still null, use some common subSchemaSubentry DNs as a work-around.

	// cn=subschema for OpenLDAP
	if ($schema_search === null) {
		// try with the standard DN
		$schema_search = @ldap_read($ds, 'cn=subschema', '(objectClass=*)',
			[$schema_to_fetch], 0, 0, 0, LDAP_DEREF_ALWAYS);
		if ($schema_search === false) {
			$schema_search = null;
		}
	}

	// cn=schema for Novell eDirectory
	if ($schema_search === null) {
		// try again, with a different schema DN
		$schema_search = @ldap_read($ds, 'cn=schema', '(objectClass=*)',
			[$schema_to_fetch], 0, 0, 0, LDAP_DEREF_ALWAYS);
		if ($schema_search === false) {
			$schema_search = null;
		}
	}

	$base_dn = null;
	// cn=schema,cn=configuration,dc=example,dc=com for ActiveDirectory
	if ($schema_search === null) {
		// try again, with a different schema DN
		global $servers;
		$base_dn = $servers['base'] ?? null;
		if ($base_dn != null) {
			$schema_search = @ldap_read($ds, 'cn=schema,cn=configuration,' . $base_dn, '(objectClass=*)',
				[$schema_to_fetch], 0, 0, 0, LDAP_DEREF_ALWAYS);
			if ($schema_search === false) {
				$schema_search = null;
			}
		}
	}

	// cn=Schema,ou=Admin,dc=example,dc=com for SiteServer
	if ($schema_search === null) {
		// try again, with a different schema DN
		global $servers;
		$base_dn = $servers['base'] ?? null;
		if ($base_dn != null) {
			$schema_search = @ldap_read($ds, 'cn=Schema,ou=Admin,' . $base_dn, '(objectClass=*)',
				[$schema_to_fetch], 0, 0, 0, LDAP_DEREF_ALWAYS);
			if ($schema_search === false) {
				$schema_search = null;
			}
		}
	}

	// Attempt to pull schema from Root DSE with scope "base"
	if (($schema_search === null) && ($base_dn !== null)) {
		// try again, with a different schema DN
		$schema_search = @ldap_read($ds, '', '(objectClass=*)',
			[$schema_to_fetch], 0, 0, 0, LDAP_DEREF_ALWAYS);
		if ($schema_search === false) {
			$schema_search = null;
		}
		else {
			$schema_entries = @ldap_get_entries($ds, $schema_search);
			if (($schema_entries === false) || !isset($schema_entries[0][$schema_to_fetch])) {
				$schema_search = null;
			}
		}
	}

	// Attempt to pull schema from Root DSE with scope "one" (work-around for Isode M-Vault X.500/LDAP)
	if (($schema_search === null) && ($base_dn !== null)) {
		// try again, with a different schema DN
		$schema_search = @ldap_list($ds, '', '(objectClass=*)',
			[$schema_to_fetch], 0, 0, 0, LDAP_DEREF_ALWAYS);
		if ($schema_search === false) {
			$schema_search = null;
		}
		else {
			$schema_entries = @ldap_get_entries($ds, $schema_search);
			if (($schema_entries === false) || !isset($schema_entries[0][$schema_to_fetch])) {
				$schema_search = null;
			}
		}
	}

	// Shall we just give up?
	if ($schema_search === null) {
		set_schema_cache_unavailable();
		return false;
	}

	$schema = @ldap_get_entries($ds, $schema_search);
	if ($schema === false) {
		set_schema_cache_unavailable();
		return false;
	}

	if (!isset($schema[0][$schema_to_fetch])) {
		set_schema_cache_unavailable();
		return false;
	}

	// Make a nice array of this form:
	// Array (
	//    [0] => "( 1.3.6.1.4.1.7165.1.2.2.4 NAME 'gidPool' DESC 'Pool ...
	//    [1] => "( 1.3.6.1.4.1.7165.2.2.3 NAME 'sambaAccount' DESC 'Sa ...
	//    etc.
	$schema = $schema[0][$schema_to_fetch];
	unset($schema['count']);

	return $schema;
}

/**
 * Gets an associative array of ObjectClass objects for the specified
 * server. Each array entry's key is the name of the objectClass
 * in lower-case and the value is an ObjectClass object.
 *
 * @param string $dn (optional) It is easier to fetch schema if a DN is provided
 *             which defines the subschemaSubEntry attribute (all entries should).
 * @param bool $use_cache use cache
 *
 * @return ObjectClass[]|false An array of ObjectClass objects.
 *
 * @see ObjectClass
 * @see get_schema_objectclass
 */
function get_schema_objectclasses($dn = null, $use_cache = true): array|false {
	if ($use_cache && cached_schema_available('objectclasses')) {
		return get_cached_schema('objectclasses');
	}

	$raw_oclasses = _get_raw_schema('objectclasses', $dn);
	if (!$raw_oclasses) {
		return [];
	}

	// build the array of objectClasses
	$object_classes = [];
	foreach ($raw_oclasses as $class_string) {
		if (empty($class_string)) {
			continue;
		}
		$object_class = new ObjectClass($class_string);
		$name = $object_class->getName();
		$key = strtolower($name);
		$object_classes[$key] = $object_class;
	}

	ksort($object_classes);

	// cache the schema to prevent multiple schema fetches from LDAP server
	set_cached_schema('objectclasses', $object_classes);
	return ($object_classes);
}

/**
 * Gets a single ObjectClass object specified by name.
 *
 * @param string $oclass_name The name of the objectClass to fetch.
 * @param string $dn (optional) It is easier to fetch schema if a DN is provided
 *             which defines the subschemaSubEntry attribute (all entries should).
 * @param bool $use_cache use cache
 *
 * @return ObjectClass|false The specified ObjectClass object or false on error.
 *
 * @see ObjectClass
 * @see get_schema_objectclasses
 */
function get_schema_objectclass($oclass_name, $dn = null, $use_cache = true): bool|ObjectClass {
	$oclass_name = strtolower($oclass_name);
	$oclasses = get_schema_objectclasses($dn, $use_cache);
	if ($oclasses === []) {
		return false;
	}
	return $oclasses[$oclass_name] ?? false;
}

/**
 * Gets an associative array of AttributeType objects for the specified
 * server. Each array entry's key is the name of the attributeType
 * in lower-case and the value is an AttributeType object.
 *
 * @param string $dn (optional) It is easier to fetch schema if a DN is provided
 *             which defines the subschemaSubEntry attribute (all entries should).
 * @param bool $use_cache use cache
 *
 * @return AttributeType[]|false an array of AttributeType objects.
 */
function get_schema_attributes($dn = null, $use_cache = true) {
	if ($use_cache && cached_schema_available('attributetypes')) {
		return get_cached_schema('attributetypes');
	}

	$raw_attrs = _get_raw_schema('attributeTypes', $dn);
	if (!$raw_attrs) {
		return [];
	}

	// build the array of attributeTypes
	$syntaxes = get_schema_syntaxes($dn);
	$attrs = [];
	/**
	 * bug 856832: create two arrays - one indexed by name (the standard
	 * $attrs array above) and one indexed by oid (the new $attrs_oid array
	 * below). This will help for directory servers, like IBM's, that use OIDs
	 * in their attribute definitions of SUP, etc
	 */
	$attrs_oid = [];
	foreach ($raw_attrs as $attr_string) {
		if (empty($attr_string)) {
			continue;
		}
		$attr = new AttributeType($attr_string);
		if (isset($syntaxes[$attr->getSyntaxOID()])) {
			$syntax = $syntaxes[$attr->getSyntaxOID()];
			$attr->setType($syntax->getDescription());
		}
		$name = $attr->getName();
		$key = strtolower($name);
		$attrs[$key] = $attr;

		/**
		 * bug 856832: create an entry in the $attrs_oid array too. This
		 * will be a ref to the $attrs entry for maintenance and performance
		 * reasons
		 */
		$oid = $attr->getOID();
		$attrs_oid[$oid] = &$attrs[$key];
	}

	add_aliases_to_attrs($attrs);
	/**
	 * bug 856832: pass the $attrs_oid array as a second (new) parameter
	 * to add_sup_to_attrs. This will allow lookups by either name or oid.
	 */
	add_sup_to_attrs($attrs, $attrs_oid);

	ksort($attrs);

	// Add the used in and required_by values.
	$schema_object_classes = get_schema_objectclasses();
	if ($schema_object_classes === false) {
		return [];
	}

	foreach ($schema_object_classes as $object_class) {
		$must_attrs = $object_class->getMustAttrNames($schema_object_classes);
		$may_attrs = $object_class->getMayAttrNames($schema_object_classes);
		$oclass_attrs = array_unique(array_merge($must_attrs, $may_attrs));

		// Add Used In.
		foreach ($oclass_attrs as $attr_name) {
			if (isset($attrs[strtolower($attr_name)])) {
				$attrs[strtolower($attr_name)]->addUsedInObjectClass(
					$object_class->getName());
			}
		}

		// Add Required By.
		foreach ($must_attrs as $attr_name) {
			if (isset($attrs[strtolower($attr_name)])) {
				$attrs[strtolower($attr_name)]->addRequiredByObjectClass(
					$object_class->getName());
			}
		}

	}

	// cache the schema to prevent multiple schema fetches from LDAP server
	set_cached_schema('attributetypes', $attrs);
	return ($attrs);
}

/**
 * For each attribute that has multiple names, this function adds unique entries to
 * the attrs array for those names. Ie, attributeType has name 'gn' and 'givenName'.
 * This function will create a unique entry for 'gn' and 'givenName'.
 *
 * @param AttributeType[] $attrs attributes
 */
function add_aliases_to_attrs(&$attrs): void {
	$toaddAttrs = [];
	// go back and add data from aliased attributeTypes
	foreach ($attrs as $attr) {
		$aliases = $attr->getAliases();
		// foreach of the attribute's aliases, create a new entry in the attrs array
		// with its name set to the alias name, and all other data copied
		foreach ($aliases as $alias_attr_name) {
			$new_attr = clone $attr;
			$new_attr->setName($alias_attr_name);
			$new_attr->addAlias($attr->getName());
			$new_attr->removeAlias($alias_attr_name);
			$new_attr_key = strtolower($alias_attr_name);
			$toaddAttrs[$new_attr_key] = $new_attr;
		}
	}
	$attrs = array_merge($attrs, $toaddAttrs);
}

/**
 * Adds inherited values to each attributeType specified by the SUP directive.
 * Supports infinite levels of inheritance.
 * Bug 856832: require a second parameter that has all attributes indexed by OID
 *
 * @param array<string, AttributeType> $attrs attributes
 * @param array<string, AttributeType> $attrs_oid attributes by OIDs
 */
function add_sup_to_attrs(&$attrs, &$attrs_oid): void {
	// go back and add any inherited descriptions from parent attributes (ie, cn inherits name)
	foreach ($attrs as $key => $attr) {
		$sup_attr_name = $attr->getSupAttribute();
		$sup_attr = null;

		// Does this attribute have any inheritance happening here?
		if (($sup_attr_name !== null) && !empty(trim($sup_attr_name))) {

			// This loop really should traverse infinite levels of inheritance (SUP) for attributeTypes,
			// but just in case we get carried away, stop at 100. This shouldn't happen, but for
			// some weird reason, we have had someone report that it has happened. Oh well.
			$i = 0;
			while ($i++ < 100/** 100 == INFINITY ;) */) {
				/**
				 * Bug 856832: check if sup is indexed by OID. If it is,
				 * replace the OID with the appropriate name. Then reset
				 * $sup_attr_name to the name instead of the OID. This will
				 * make all the remaining code in this function work as
				 * expected.
				 */
				if (isset($attrs_oid[$sup_attr_name])) {
					$attr->setSupAttribute($attrs_oid[$sup_attr_name]->getName());
					$sup_attr_name = $attr->getSupAttribute();
				}

				if (!isset($attrs[strtolower($sup_attr_name)])) {
					echo("Schema error: attributeType '" . $attr->getName() . "' inherits from
								'" . $sup_attr_name . "', but attributeType '" . $sup_attr_name . "' does not
								exist.");
					return;
				}

				$sup_attr = $attrs[strtolower($sup_attr_name)];

				$sup_attr_name = $sup_attr->getSupAttribute();

				// Does this superior attributeType not have a superior attributeType?
				if (null == $sup_attr_name || trim($sup_attr_name) === '') {

					// Since this attribute's superior attribute does not have another superior
					// attribute, clone its properties for this attribute. Then, replace
					// those cloned values with those that can be explicitly set by the child
					// attribute attr). Save those few properties which the child can set here:
					$tmp_name = $attr->getName();
					$tmp_oid = $attr->getOID();
					$tmp_sup = $attr->getSupAttribute();
					$tmp_aliases = $attr->getAliases();
					$tmp_single_val = $attr->getIsSingleValue();


					// clone the SUP attributeType and populate those values
					// that were set by the child attributeType
					$attr = clone $sup_attr;
					$attr->setOID($tmp_oid);
					$attr->setName($tmp_name);
					$attr->setSupAttribute($tmp_sup);
					$attr->setAliases($tmp_aliases);

					// only overwrite the SINGLE-VALUE property if the child explicitly sets it
					// (note: All LDAP attributes default to multi-value if not explicitly set SINGLE-VALUE)
					if (true === $tmp_single_val) {
						$attr->setIsSingleValue(true);
					}

					// replace this attribute in the attrs array now that we have populated
					// new values therein
					$attrs[$key] = $attr;

					// very important: break out after we are done with this attribute
					$sup_attr_name = null;
					$sup_attr = null;
					break;

				}
			}
		}
	}
}

/**
 * @param string $dn DN
 * @param bool $use_cache use cache
 * @return MatchingRule[]|false Returns an array of MatchingRule objects for the specified server.
 * The key of each entry is the OID of the matching rule.
 */
function get_schema_matching_rules($dn = null, $use_cache = true) {
	if ($use_cache && cached_schema_available('matchingrules')) {
		return get_cached_schema('matchingrules');
	}

	// build the array of MatchingRule objects
	$raw_matching_rules = _get_raw_schema('matchingRules', $dn);
	if (!$raw_matching_rules) {
		return false;
	}
	$rules = [];
	foreach ($raw_matching_rules as $rule_string) {
		if (empty($rule_string)) {
			continue;
		}
		$rule = new MatchingRule($rule_string);
		$key = strtolower($rule->getName());
		$rules[$key] = $rule;
	}

	ksort($rules);

	// For each MatchingRuleUse entry, add the attributes who use it to the
	// MatchingRule in the $rules array.
	$raw_matching_rule_use = _get_raw_schema('matchingRuleUse');
	if ($raw_matching_rule_use !== false) {
		foreach ($raw_matching_rule_use as $rule_use_string) {
			if (empty($rule_use_string)) {
				continue;
			}
			$rule_use = new MatchingRuleUse($rule_use_string);
			$key = strtolower($rule_use->getName());
			if (isset($rules[$key])) {
				$rules[$key]->setUsedByAttrs($rule_use->getUsedByAttrs());
			}
		}
	}
	else {
		// No MatchingRuleUse entry in the subschema, so brute-forcing
		// the reverse-map for the "$rule->getUsedByAttrs()" data.
		$attrs = get_schema_attributes($dn);
		foreach ($attrs as $attr) {
			$rule_key = strtolower($attr->getEquality());
			if (isset($rules[$rule_key])) {
				$rules[$rule_key]->addUsedByAttr($attr->getName());
			}
		}
	}

	// cache the schema to prevent multiple schema fetches from LDAP server
	set_cached_schema('matchingrules', $rules);
	return $rules;
}

/**
 * @param string $dn DN
 * @param bool $use_cache use cache
 * @return Syntax[]|false returns an array of Syntax objects that this LDAP server uses mapped to
 * their descriptions. The key of each entry is the OID of the Syntax.
 */
function get_schema_syntaxes($dn = null, $use_cache = true) {
	if ($use_cache && cached_schema_available('ldapsyntaxes')) {
		return get_cached_schema('ldapsyntaxes');
	}

	$raw_syntaxes = _get_raw_schema('ldapSyntaxes', $dn);
	if (!$raw_syntaxes) {
		return false;
	}

	// build the array of attributes
	$syntaxes = [];
	foreach ($raw_syntaxes as $syntax_string) {
		$syntax = new Syntax($syntax_string);
		$key = strtolower(trim($syntax->getOID()));
		if ($key === '') {
			continue;
		}
		$syntaxes[$key] = $syntax;
	}

	ksort($syntaxes);

	// cache the schema to prevent multiple schema fetches from LDAP server
	set_cached_schema('ldapsyntaxes', $syntaxes);

	return $syntaxes;
}

// --------------------------------------------------------------------
// Schema caching functions
// --------------------------------------------------------------------

/**
 * @return bool returns true if the schema for $schema_type has been cached and
 * is available. $schema_type may be one of (lowercase) the following:
 *      objectclasses
 *      attributetypes
 *      ldapsyntaxes
 *      matchingrules
 *      matchingruleuse
 * Note that _get_raw_schema() takes a similar parameter.
 *
 * @param string $schema_type type
 */
function cached_schema_available($schema_type): bool {
	// Static memory cache available?
	// (note: this memory cache buys us a 20% speed improvement over strictly
	//        checking the session, ie 0.05 to 0.04 secs)
	$schema_type = strtolower($schema_type);
	static $cache_avail;
	if (isset($cache_avail[$schema_type])) {
		return true;
	}

	// Session cache available?
	if (isset($_SESSION['schema'][$schema_type])) {
		$cache_avail[$schema_type] = true;
		return true;
	}
	return false;
}

/**
 * Returns the cached array of schemaitem objects for the specified
 * $schema_type. For list of valid $schema_type values, see above
 * schema_cache_available(). Note that internally, this function
 * utilizes a two-layer cache, one in memory using a static variable
 * for multiple calls within the same page load, and one in a session
 * for multiple calls within the same user session (spanning multiple
 * page loads).
 *
 * @param string $schema_type type
 * @return SchemaItem[]|false returns an array of SchemaItem objects on success or false on failure.
 */
function get_cached_schema($schema_type) {
	static $cache;
	$schema_type = strtolower($schema_type);
	if (isset($cache[$schema_type])) {
		//echo "Getting memory-cached schema for \"$schema_type\"...<br />\n";
		return $cache[$schema_type];
	}

	//echo "Getting session-cached schema for \"$schema_type\"...<br />\n";
	if (cached_schema_available($schema_type) && array_key_exists($schema_type, $_SESSION['schema'])) {
		$schema = $_SESSION['schema'][$schema_type];
		$cache[$schema_type] = $schema;
		return $schema;
	}
	else {
		return false;
	}
}

/**
 * Caches the specified $schema_type.
 * $schema_items should be an array of SchemaItem instances (ie,
 * an array of ObjectClass, AttributeType, LDAPSyntax, MatchingRuleUse,
 * or MatchingRule objects.
 *
 * @param string $schema_type type
 * @param SchemaItem[] $schema_items items
 */
function set_cached_schema($schema_type, array $schema_items): void {
	$schema_type = strtolower($schema_type);
	$_SESSION['schema'][$schema_type] = $schema_items;
}

/**
 * Sets the schema entry for the server_id to be "unavailable" so that we realize
 * that we tried to get the schema but could not, so quit trying next time to
 * fetch it from the server.
 */
function set_schema_cache_unavailable(): void {
	$_SESSION['schema']['unavailable'] = true;
}
