1<?php
2
3/**
4 * This file is part of the FreeDSx LDAP package.
5 *
6 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace FreeDSx\Ldap\Entry;
13
14use ArrayIterator;
15use Countable;
16use IteratorAggregate;
17use Traversable;
18use function array_keys;
19use function array_search;
20use function array_shift;
21use function array_values;
22use function count;
23use function explode;
24use function implode;
25use function str_replace;
26use function strpos;
27use function strtolower;
28
29/**
30 * Represents an entry attribute and any values.
31 *
32 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
33 */
34class Attribute implements IteratorAggregate, Countable
35{
36    use EscapeTrait;
37
38    protected const ESCAPE_MAP = [
39        '\\' => '\5c',
40        '*' => '\2a',
41        '(' => '\28',
42        ')' => '\29',
43        "\x00" => '\00',
44    ];
45
46    /**
47     * @var string
48     */
49    protected $attribute;
50
51    /**
52     * @var null|string
53     */
54    protected $lcAttribute;
55
56    /**
57     * @var mixed[]|string[]
58     */
59    protected $values = [];
60
61    /**
62     * @var null|Options
63     */
64    protected $options;
65
66    /**
67     * @param string $attribute
68     * @param mixed|string ...$values
69     */
70    public function __construct(string $attribute, ...$values)
71    {
72        $this->attribute = $attribute;
73        $this->values = $values;
74    }
75
76    /**
77     * Add a value, or values, to the attribute.
78     *
79     * @param mixed|string ...$values
80     * @return $this
81     */
82    public function add(...$values): self
83    {
84        foreach ($values as $value) {
85            $this->values[] = $value;
86        }
87
88        return $this;
89    }
90
91    /**
92     * Check if the attribute has a specific value.
93     *
94     * @param mixed|string $value
95     * @return bool
96     */
97    public function has($value): bool
98    {
99        return array_search($value, $this->values, true) !== false;
100    }
101
102    /**
103     * Remove a specific value, or values, from an attribute.
104     *
105     * @param mixed|string ...$values
106     * @return $this
107     */
108    public function remove(...$values): self
109    {
110        foreach ($values as $value) {
111            if (($i = array_search($value, $this->values, true)) !== false) {
112                unset($this->values[$i]);
113            }
114        }
115
116        return $this;
117    }
118
119    /**
120     * Resets the values to any empty array.
121     *
122     * @return $this
123     */
124    public function reset(): self
125    {
126        $this->values = [];
127
128        return $this;
129    }
130
131    /**
132     * Set the values for the attribute.
133     *
134     * @param mixed|string ...$values
135     * @return $this
136     */
137    public function set(...$values): self
138    {
139        $this->values = $values;
140
141        return $this;
142    }
143
144    /**
145     * Gets the name (AttributeType) portion of the AttributeDescription, which excludes the options.
146     *
147     * @return string
148     */
149    public function getName(): string
150    {
151        $this->options();
152
153        return $this->attribute;
154    }
155
156    /**
157     * Gets the full AttributeDescription (RFC 4512, 2.5), which contains the attribute type (name) and options.
158     *
159     * @return string
160     */
161    public function getDescription(): string
162    {
163        return $this->getName() . ($this->options()->count() > 0 ? ';' . $this->options()->toString() : '');
164    }
165
166    /**
167     * Gets any values associated with the attribute.
168     *
169     * @return array
170     */
171    public function getValues(): array
172    {
173        return $this->values;
174    }
175
176    /**
177     * Retrieve the first value of the attribute.
178     *
179     * @return string|mixed|null
180     */
181    public function firstValue()
182    {
183        return $this->values[0] ?? null;
184    }
185
186    /**
187     * Retrieve the last value of the attribute.
188     *
189     * @return string|mixed|null
190     */
191    public function lastValue()
192    {
193        $last = end($this->values);
194        reset($this->values);
195
196        return $last === false ? null : $last;
197    }
198
199    /**
200     * Gets the options within the AttributeDescription (semi-colon separated list of options).
201     *
202     * @return Options
203     */
204    public function getOptions(): Options
205    {
206        return $this->options();
207    }
208
209    /**
210     * @return bool
211     */
212    public function hasOptions(): bool
213    {
214        return ($this->options()->count() > 0);
215    }
216
217    /**
218     * {@inheritDoc}
219     */
220    public function getIterator(): Traversable
221    {
222        return new ArrayIterator($this->values);
223    }
224
225    /**
226     * {@inheritDoc}
227     */
228    public function count(): int
229    {
230        return count($this->values);
231    }
232
233    /**
234     * @param Attribute $attribute
235     * @param bool $strict If set to true, then options must also match.
236     * @return bool
237     */
238    public function equals(Attribute $attribute, bool $strict = false): bool
239    {
240        $this->options();
241        $attribute->options();
242        if ($this->lcAttribute === null) {
243            $this->lcAttribute = strtolower($this->attribute);
244        }
245        if ($attribute->lcAttribute === null) {
246            $attribute->lcAttribute = strtolower($attribute->attribute);
247        }
248        $nameMatches = ($this->lcAttribute === $attribute->lcAttribute);
249
250        # Only the name of the attribute is checked for by default.
251        # If strict is selected, or the attribute to be checked has explicit options, then the opposing attribute must too
252        if ($strict || $attribute->hasOptions()) {
253            return $nameMatches && ($this->getOptions()->toString(true) === $attribute->getOptions()->toString(true));
254        }
255
256        return $nameMatches;
257    }
258
259    /**
260     * @return string
261     */
262    public function __toString(): string
263    {
264        return implode(', ', $this->values);
265    }
266
267    /**
268     * Escape an attribute value for a filter.
269     *
270     * @param string $value
271     * @return string
272     */
273    public static function escape(string $value): string
274    {
275        if (self::shouldNotEscape($value)) {
276            return $value;
277        }
278        $value = str_replace(array_keys(self::ESCAPE_MAP), array_values(self::ESCAPE_MAP), $value);
279
280        return self::escapeNonPrintable($value);
281    }
282
283    /**
284     * A one time check and load of any attribute options.
285     */
286    protected function options(): Options
287    {
288        if ($this->options !== null) {
289            return $this->options;
290        }
291        if (strpos($this->attribute, ';') === false) {
292            $this->options = new Options();
293
294            return $this->options;
295        }
296        $options = explode(';', $this->attribute);
297        $this->attribute = (string) array_shift($options);
298        $this->options = new Options(...$options);
299
300        return $this->options;
301    }
302}
303