Lines 100.00% 255 / 255
Functions and Methods 100.00% 59 / 59
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Collection 100.00% 255 / 255 100.00% 59 / 59 158 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 2
 make 100.00% 1 / 1 100.00% 1 / 1 1
 wrap 100.00% 3 / 3 100.00% 1 / 1 3
 times 100.00% 7 / 7 100.00% 1 / 1 3
 range 100.00% 3 / 3 100.00% 1 / 1 2
 value 100.00% 5 / 5 100.00% 1 / 1 3
 map 100.00% 4 / 4 100.00% 1 / 1 2
 filter 100.00% 5 / 5 100.00% 1 / 1 3
 pluck 100.00% 7 / 7 100.00% 1 / 1 3
 keyBy 100.00% 4 / 4 100.00% 1 / 1 2
 groupBy 100.00% 5 / 5 100.00% 1 / 1 3
 mapWithKeys 100.00% 6 / 6 100.00% 1 / 1 3
 flatMap 100.00% 1 / 1 100.00% 1 / 1 1
 collapse 100.00% 5 / 5 100.00% 1 / 1 3
 filterMap 100.00% 7 / 7 100.00% 1 / 1 3
 reject 100.00% 1 / 1 100.00% 1 / 1 1
 reduce 100.00% 4 / 4 100.00% 1 / 1 2
 sum 100.00% 11 / 11 100.00% 1 / 1 5
 avg 100.00% 2 / 2 100.00% 1 / 1 2
 min 100.00% 10 / 10 100.00% 1 / 1 7
 max 100.00% 10 / 10 100.00% 1 / 1 7
 count 100.00% 1 / 1 100.00% 1 / 1 1
 contains 100.00% 3 / 3 100.00% 1 / 1 2
 where 100.00% 1 / 1 100.00% 1 / 1 1
 every 100.00% 4 / 4 100.00% 1 / 1 3
 some 100.00% 4 / 4 100.00% 1 / 1 3
 isEmpty 100.00% 1 / 1 100.00% 1 / 1 1
 isNotEmpty 100.00% 1 / 1 100.00% 1 / 1 1
 sole 100.00% 16 / 16 100.00% 1 / 1 9
 firstWhere 100.00% 4 / 4 100.00% 1 / 1 3
 whereNull 100.00% 3 / 3 100.00% 1 / 1 2
 whereNotNull 100.00% 3 / 3 100.00% 1 / 1 2
 implode 100.00% 4 / 4 100.00% 1 / 1 3
 each 100.00% 4 / 4 100.00% 1 / 1 3
 first 100.00% 7 / 7 100.00% 1 / 1 5
 last 100.00% 7 / 7 100.00% 1 / 1 5
 values 100.00% 1 / 1 100.00% 1 / 1 1
 keys 100.00% 1 / 1 100.00% 1 / 1 1
 take 100.00% 1 / 1 100.00% 1 / 1 1
 skip 100.00% 1 / 1 100.00% 1 / 1 1
 slice 100.00% 1 / 1 100.00% 1 / 1 1
 takeWhile 100.00% 6 / 6 100.00% 1 / 1 3
 skipWhile 100.00% 7 / 7 100.00% 1 / 1 4
 takeUntil 100.00% 6 / 6 100.00% 1 / 1 3
 skipUntil 100.00% 7 / 7 100.00% 1 / 1 4
 unique 100.00% 11 / 11 100.00% 1 / 1 4
 isSeen 100.00% 11 / 11 100.00% 1 / 1 6
 sort 100.00% 3 / 3 100.00% 1 / 1 2
 sortBy 100.00% 21 / 21 100.00% 1 / 1 11
 reverse 100.00% 1 / 1 100.00% 1 / 1 1
 toArray 100.00% 4 / 4 100.00% 1 / 1 2
 toJson 100.00% 1 / 1 100.00% 1 / 1 1
 jsonSerialize 100.00% 1 / 1 100.00% 1 / 1 1
 all 100.00% 1 / 1 100.00% 1 / 1 1
 offsetExists 100.00% 1 / 1 100.00% 1 / 1 1
 offsetGet 100.00% 1 / 1 100.00% 1 / 1 1
 offsetSet 100.00% 1 / 1 100.00% 1 / 1 1
 offsetUnset 100.00% 1 / 1 100.00% 1 / 1 1
 getIterator 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Collections;
6
7/**
8 * A pure, standalone array wrapper with no framework coupling.
9 *
10 * @template  TKey of array-key
11 * @template  TValue
12 *
13 * @phpstan-consistent-constructor
14 *
15 * @implements  Enumerable<TKey, TValue>
16 * @implements  \ArrayAccess<TKey, TValue>
17 */
18class Collection implements Enumerable, \Countable, \ArrayAccess
19{
20    /** @var array<TKey, TValue> */
21    protected array $items = [];
22
23    /**
24     * Create a new collection from the given items.
25     *
26     * The constructor is protected â€” use `make()` (or another factory) to
27     * create instances from outside the class.
28     *
29     * @param  iterable<TKey, TValue> $items
30     */
31    protected function __construct(iterable $items = [])
32    {
33        $this->items = is_array($items) ? $items : iterator_to_array($items);
34    }
35
36    /**
37     * Create a new collection from the given items.
38     *
39     * Convenience static factory equivalent to `new static($items)`.
40     *
41     * @template  TMakeKey of array-key
42     * @template  TMakeValue
43     *
44     * @param  iterable<TMakeKey, TMakeValue> $items
45     * @return  static<TMakeKey, TMakeValue>
46     */
47    public static function make(iterable $items = []): static
48    {
49        return new static($items);
50    }
51
52    /**
53     * Wrap the given value in a collection.
54     *
55     * If the value is already a collection it is returned unchanged; if it is
56     * an array it is used directly; otherwise it is wrapped in a single-item
57     * collection. Useful for normalizing "one or many" inputs.
58     *
59     * @template  TWrapValue
60     *
61     * @param  TWrapValue $value
62     * @return  (TWrapValue is static ? TWrapValue : (TWrapValue is array ? static<int|string, value-of<TWrapValue>> : static<int, TWrapValue>))
63     */
64    public static function wrap(mixed $value): static
65    {
66        if ($value instanceof static) {
67            return $value;
68        }
69
70        return new static(is_array($value) ? $value : [$value]);
71    }
72
73    /**
74     * Create a collection by invoking the callback N times.
75     *
76     * The callback receives the 1-based index.
77     *
78     * @template  TTimesValue
79     *
80     * @param  int $number
81     * @param  (callable(int): TTimesValue)|null $callback
82     * @return  ($callback is null ? static<int, int> : static<int, TTimesValue>)
83     */
84    public static function times(int $number, ?callable $callback = null): static
85    {
86        if ($number < 1) {
87            return new static();
88        }
89
90        if ($callback === null) {
91            /** @var static<int, int> $sequence */
92            $sequence = new static(range(1, $number));
93
94            return $sequence;
95        }
96
97        /** @var static<int, TTimesValue> $result */
98        $result = (new static(range(1, $number)))->map($callback);
99
100        return $result;
101    }
102
103    /**
104     * Create a collection of numbers in the given range.
105     *
106     * Mirrors PHP's `range()`: when $to is less than $from, the sequence is
107     * generated in descending order. A step of zero returns an empty
108     * collection rather than throwing.
109     *
110     * @param  int|float|string $from
111     * @param  int|float|string $to
112     * @param  int|float $step
113     * @return  static<int, int|float|string>
114     */
115    public static function range(int|float|string $from, int|float|string $to, int|float $step = 1): static
116    {
117        if ($step == 0) {
118            return new static();
119        }
120        return new static(range($from, $to, $step));
121    }
122
123    /**
124     * Get a value from an array or object, or null when the key is missing.
125     *
126     * A missing object property dispatches magic `__get()`, which may run
127     * arbitrary consumer code.
128     *
129     * @param  TValue $item
130     * @param  (TValue is array ? key-of<TValue> : string) $key
131     * @return  (TValue is array ? value-of<TValue> : mixed)
132     */
133    final protected function value(mixed $item, int|string $key): mixed
134    {
135        if (is_array($item)) {
136            return $item[$key] ?? null;
137        }
138        if (is_object($item)) {
139            return $item->{$key} ?? null;
140        }
141        return null;
142    }
143
144    // ---- Transforms (immutable â€” return new instances) ----
145
146    /**
147     * Map each item through a callback, producing a new collection.
148     *
149     * The callback receives the item and its key. Keys are preserved.
150     *
151     * @template  TNewValue
152     * @param  callable(TValue, TKey): TNewValue $callback
153     * @return  static<TKey, TNewValue>
154     */
155    public function map(callable $callback): static
156    {
157        $results = [];
158        foreach ($this->items as $key => $item) {
159            $results[$key] = $callback($item, $key);
160        }
161        return new static($results);
162    }
163
164    /**
165     * Filter the collection to items that pass the given callback.
166     *
167     * When no callback is given, truthy items are kept. Keys are preserved.
168     *
169     * @param  (callable(TValue, TKey): bool)|null $callback
170     * @return  static
171     */
172    public function filter(?callable $callback = null): static
173    {
174        $results = [];
175        foreach ($this->items as $key => $item) {
176            if (($callback ?? fn ($v) => (bool) $v)($item, $key)) {
177                $results[$key] = $item;
178            }
179        }
180        return new static($results);
181    }
182
183    /**
184     * Pluck a single column's value from each item.
185     *
186     * When a key column is given, the result is keyed by that column's value;
187     * otherwise the result is a plain list.
188     *
189     * @param  (TValue is array ? key-of<TValue> : string) $value
190     * @param  ((TValue is array ? key-of<TValue> : string)|null) $key
191     * @return  static<($key is null ? int : int|string), (TValue is array ? value-of<TValue> : mixed)>
192     */
193    public function pluck(int|string $value, int|string|null $key = null): static
194    {
195        $results = [];
196        foreach ($this->items as $item) {
197            $itemValue = $this->value($item, $value);
198            if ($key === null) {
199                $results[] = $itemValue;
200            } else {
201                $results[$this->value($item, $key)] = $itemValue;
202            }
203        }
204        return new static($results);
205    }
206
207    /**
208     * Re-key the collection by a given column's value.
209     *
210     * Later items with a duplicate key overwrite earlier ones.
211     *
212     * @param  (TValue is array ? key-of<TValue> : string) $key
213     * @return  static<int|string, TValue>
214     */
215    public function keyBy(int|string $key): static
216    {
217        $results = [];
218        foreach ($this->items as $item) {
219            $results[$this->value($item, $key)] = $item;
220        }
221        return new static($results);
222    }
223
224    /**
225     * Group the collection's items by a column or callback.
226     *
227     * The result is keyed by the group value, each holding a list of the
228     * items in that group.
229     *
230     * @param  ((TValue is array ? key-of<TValue> : string)|callable(TValue, TKey): mixed) $groupBy
231     * @return  static<(int|string), non-empty-list<TValue>>
232     */
233    public function groupBy(int|string|callable $groupBy): static
234    {
235        $results = [];
236        foreach ($this->items as $key => $item) {
237            $groupKey = is_callable($groupBy) ? $groupBy($item, $key) : $this->value($item, $groupBy);
238            $results[$groupKey][] = $item;
239        }
240        return new static($results);
241    }
242
243    /**
244     * Map each item to an associative array and merge the results.
245     *
246     * The callback must return an array; later keys overwrite earlier ones.
247     *
248     * @template  TMapKey of array-key
249     * @template  TMapValue
250     * @param  callable(TValue, TKey): array<TMapKey, TMapValue> $callback
251     * @return  static<TMapKey, TMapValue>
252     */
253    public function mapWithKeys(callable $callback): static
254    {
255        $results = [];
256        foreach ($this->items as $key => $item) {
257            $assoc = $callback($item, $key);
258            foreach ($assoc as $mapKey => $mapValue) {
259                $results[$mapKey] = $mapValue;
260            }
261        }
262        return new static($results);
263    }
264
265    /**
266     * Map each item through a callback, then collapse the result one level.
267     *
268     * Equivalent to `map($callback)->collapse()`. String keys survive the
269     * collapse and integer keys are renumbered, so the resulting key type is
270     * int|string.
271     *
272     * @param  callable(TValue, TKey): mixed $callback
273     * @return  static<int|string, mixed>
274     */
275    public function flatMap(callable $callback): static
276    {
277        return $this->map($callback)->collapse();
278    }
279
280    /**
281     * Collapse a collection of arrays into a single flat collection.
282     *
283     * Non-array items are skipped. Integer keys are renumbered by the merge
284     * and string keys are preserved, so the resulting key type is int|string.
285     *
286     * @return  static<int|string, (TValue is array ? value-of<TValue> : mixed)>
287     */
288    public function collapse(): static
289    {
290        $results = [];
291        foreach ($this->items as $item) {
292            if (is_array($item)) {
293                $results = array_merge($results, $item);
294            }
295        }
296        return new static($results);
297    }
298
299    /**
300     * Map each item through a callback, dropping null results.
301     *
302     * Keys are preserved â€” call `values()` to renumber to a 0-based list.
303     *
304     * @template  TNewValue
305     * @param  callable(TValue, TKey): (TNewValue|null) $callback
306     * @return  static<TKey, TNewValue>
307     */
308    public function filterMap(callable $callback): static
309    {
310        $results = [];
311        foreach ($this->items as $key => $item) {
312            $mapped = $callback($item, $key);
313            if ($mapped !== null) {
314                $results[$key] = $mapped;
315            }
316        }
317
318        /** @var static<TKey, TNewValue> $result */
319        $result = new static($results);
320
321        return $result;
322    }
323
324    /**
325     * Filter the collection to items that fail the given callback.
326     *
327     * When no callback is given, truthy items are dropped. Keys are preserved.
328     *
329     * @param  (callable(TValue, TKey): bool)|null $callback
330     * @return  static
331     */
332    public function reject(?callable $callback = null): static
333    {
334        return $this->filter(fn ($item, $key) => !($callback ?? fn ($v) => (bool) $v)($item, $key));
335    }
336
337    // ---- Reductions ----
338
339    /**
340     * Reduce the collection to a single value using a callback.
341     *
342     * The callback receives the carry, the item, and the item's key.
343     *
344     * @template  TCarry
345     * @param  callable(TCarry, TValue, TKey): TCarry $callback
346     * @param  TCarry $initial
347     * @return  TCarry
348     */
349    public function reduce(callable $callback, mixed $initial = null): mixed
350    {
351        $carry = $initial;
352        foreach ($this->items as $key => $item) {
353            $carry = $callback($carry, $item, $key);
354        }
355        return $carry;
356    }
357
358    /**
359     * Sum the collection's values, or a single column of each item.
360     *
361     * Non-numeric items throw a TypeError, matching the lazy implementation.
362     *
363     * @param  (TValue is array ? key-of<TValue> : string)|(callable(TValue): (int|float))|null $column
364     * @return  int|float
365     */
366    public function sum(int|string|callable|null $column = null): int|float
367    {
368        if ($column === null) {
369            $sum = 0;
370            foreach ($this->items as $item) {
371                $sum += $item;
372            }
373            return $sum;
374        }
375        if (is_callable($column)) {
376            $sum = 0;
377            foreach ($this->items as $item) {
378                $sum += $column($item);
379            }
380            return $sum;
381        }
382        return $this->pluck($column)->sum();
383    }
384
385    /**
386     * Average the collection's values, or a single column of each item.
387     *
388     * Returns 0 for an empty collection.
389     *
390     * @param  (TValue is array ? key-of<TValue> : string)|(callable(TValue): (int|float))|null $column
391     * @return  int|float
392     */
393    public function avg(int|string|callable|null $column = null): int|float
394    {
395        $count = $this->count();
396        return $count ? $this->sum($column) / $count : 0;
397    }
398
399    /**
400     * Get the minimum value, or the minimum of a single column.
401     *
402     * Returns null for an empty collection. Comparison uses PHP's `<`
403     * operator, which assumes homogeneous, comparable values.
404     *
405     * @template  TColumn
406     * @param  (callable(TValue): TColumn)|(TValue is array ? key-of<TValue> : string)|null $column
407     * @return  ($column is null ? (TValue is array ? value-of<TValue> : mixed)|null : ($column is callable ? TColumn : (TValue is array ? value-of<TValue> : mixed)))
408     */
409    public function min(int|string|callable|null $column = null): mixed
410    {
411        $min = null;
412        $has = false;
413        foreach ($this->items as $item) {
414            $value = $column === null
415                ? $item
416                : (is_callable($column) ? $column($item) : $this->value($item, $column));
417            if (!$has || $value < $min) {
418                $min = $value;
419                $has = true;
420            }
421        }
422        return $has ? $min : null;
423    }
424
425    /**
426     * Get the maximum value, or the maximum of a single column.
427     *
428     * Returns null for an empty collection. Comparison uses PHP's `>`
429     * operator, which assumes homogeneous, comparable values.
430     *
431     * @template  TColumn
432     * @param  (callable(TValue): TColumn)|(TValue is array ? key-of<TValue> : string)|null $column
433     * @return  ($column is null ? (TValue is array ? value-of<TValue> : mixed)|null : ($column is callable ? TColumn : (TValue is array ? value-of<TValue> : mixed)))
434     */
435    public function max(int|string|callable|null $column = null): mixed
436    {
437        $max = null;
438        $has = false;
439        foreach ($this->items as $item) {
440            $value = $column === null
441                ? $item
442                : (is_callable($column) ? $column($item) : $this->value($item, $column));
443            if (!$has || $value > $max) {
444                $max = $value;
445                $has = true;
446            }
447        }
448        return $has ? $max : null;
449    }
450
451    /**
452     * Count the number of items in the collection.
453     *
454     * @return  int
455     */
456    public function count(): int
457    {
458        return count($this->items);
459    }
460
461    /**
462     * Determine whether the collection contains a given item.
463     *
464     * With one argument, membership is strict value equality â€” a callable
465     * value is checked as a value, never invoked as a predicate. With a
466     * column and value, the column is compared using the given operator.
467     *
468     * @param  mixed  $key  A value to find, or a column name for the comparison form.
469     * @param  mixed  $value  The value the column is compared against.
470     * @param  ComparisonOperator  $operator
471     * @return  bool
472     */
473    public function contains(mixed $key, mixed $value = null, ComparisonOperator $operator = ComparisonOperator::Equals): bool
474    {
475        if (func_num_args() === 1) {
476            return in_array($key, $this->items, true);
477        }
478
479        return $this->some(fn ($item) => $operator->compare($this->value($item, $key), $value));
480    }
481
482    /**
483     * Filter the collection to items whose column matches a value.
484     *
485     * Keys are preserved. On a collection of scalar items, `value()` returns
486     * null for every item, so the filter keeps nothing (or everything, for
487     * NotEquals/NotIn) â€” use `filter()` with a predicate instead.
488     *
489     * @param  (TValue is array ? key-of<TValue> : string) $key
490     * @param  mixed $value
491     * @param  ComparisonOperator $operator
492     * @return  static
493     */
494    public function where(mixed $key, mixed $value = null, ComparisonOperator $operator = ComparisonOperator::Equals): static
495    {
496        return $this->filter(fn ($item) => $operator->compare($this->value($item, $key), $value));
497    }
498
499    /**
500     * Determine whether every item passes the given callback.
501     *
502     * Returns true for an empty collection.
503     *
504     * @param  callable(TValue, TKey): bool $callback
505     * @return  bool
506     */
507    public function every(callable $callback): bool
508    {
509        foreach ($this->items as $key => $item) {
510            if (!$callback($item, $key)) {
511                return false;
512            }
513        }
514        return true;
515    }
516
517    /**
518     * Determine whether any item passes the given callback.
519     *
520     * @param  callable(TValue, TKey): bool $callback
521     * @return  bool
522     */
523    public function some(callable $callback): bool
524    {
525        foreach ($this->items as $key => $item) {
526            if ($callback($item, $key)) {
527                return true;
528            }
529        }
530        return false;
531    }
532
533    /**
534     * Determine whether the collection is empty.
535     *
536     * @return  bool
537     */
538    public function isEmpty(): bool
539    {
540        return $this->items === [];
541    }
542
543    /**
544     * Determine whether the collection is not empty.
545     *
546     * @return  bool
547     */
548    public function isNotEmpty(): bool
549    {
550        return $this->items !== [];
551    }
552
553    /**
554     * Get the single item matching the given criteria.
555     *
556     * With no arguments the collection must hold exactly one item. With one
557     * argument, matching is strict value equality â€” a callable value is
558     * checked as a value, never invoked as a predicate. With a column and
559     * value, the column is compared using the given operator.
560     *
561     * @param  mixed  $key  A value to find, or a column name for the comparison form.
562     * @param  mixed  $value  The value the column is compared against.
563     * @param  ComparisonOperator  $operator
564     * @return  TValue
565     *
566     * @throws \LogicException When no item, two items, or three or more items match.
567     */
568    public function sole(mixed $key = null, mixed $value = null, ComparisonOperator $operator = ComparisonOperator::Equals): mixed
569    {
570        $predicate = match (func_num_args()) {
571            0 => fn ($item, $itemKey) => true,
572            1 => fn ($item, $itemKey) => $item === $key,
573            default => fn ($item, $itemKey) => $operator->compare($this->value($item, $key), $value),
574        };
575
576        $found = null;
577        $matches = 0;
578        foreach ($this->items as $itemKey => $item) {
579            if ($predicate($item, $itemKey)) {
580                $found = $item;
581                if (++$matches === 3) {
582                    throw new \LogicException('2 or more items match â€” the collection must contain exactly one matching item.');
583                }
584            }
585        }
586
587        if ($matches === 0) {
588            throw new \LogicException('Item not found â€” the collection must contain exactly one matching item.');
589        }
590        if ($matches === 2) {
591            throw new \LogicException('2 items match â€” the collection must contain exactly one matching item.');
592        }
593
594        /** @var TValue $found */
595        return $found;
596    }
597
598    /**
599     * Get the first item whose column matches a value.
600     *
601     * Returns null when nothing matches â€” chain `first()` for a custom default.
602     *
603     * @param  (TValue is array ? key-of<TValue> : string) $key
604     * @param  mixed $value
605     * @param  ComparisonOperator $operator
606     * @return  TValue|null
607     */
608    public function firstWhere(int|string $key, mixed $value = null, ComparisonOperator $operator = ComparisonOperator::Equals): mixed
609    {
610        foreach ($this->items as $item) {
611            if ($operator->compare($this->value($item, $key), $value)) {
612                return $item;
613            }
614        }
615        return null;
616    }
617
618    /**
619     * Filter the collection to items that are null.
620     *
621     * With no column, the item itself is checked; otherwise the column's
622     * value is checked. Keys are preserved.
623     *
624     * @param  ((TValue is array ? key-of<TValue> : string)|null) $key
625     * @return  static
626     */
627    public function whereNull(int|string|null $key = null): static
628    {
629        return $this->filter(function ($item) use ($key) {
630            return $key === null ? $item === null : $this->value($item, $key) === null;
631        });
632    }
633
634    /**
635     * Filter the collection to items that are not null.
636     *
637     * With no column, the item itself is checked; otherwise the column's
638     * value is checked. Keys are preserved.
639     *
640     * @param  ((TValue is array ? key-of<TValue> : string)|null) $key
641     * @return  static
642     */
643    public function whereNotNull(int|string|null $key = null): static
644    {
645        return $this->filter(function ($item) use ($key) {
646            return $key === null ? $item !== null : $this->value($item, $key) !== null;
647        });
648    }
649
650    /**
651     * Join the collection's values, or a single column of each item.
652     *
653     * @param  string $glue
654     * @param  ((TValue is array ? key-of<TValue> : string)|null) $column
655     * @return  string
656     */
657    public function implode(string $glue, int|string|null $column = null): string
658    {
659        $parts = [];
660        foreach ($this->items as $item) {
661            $parts[] = (string) ($column === null ? $item : $this->value($item, $column));
662        }
663        return implode($glue, $parts);
664    }
665
666    // ---- Iteration ----
667
668    /**
669     * Execute a callback over each item.
670     *
671     * Returning `false` from the callback stops iteration early. The
672     * collection is returned unchanged for chaining.
673     *
674     * @param  callable(TValue, TKey): mixed $callback
675     * @return  $this
676     */
677    public function each(callable $callback): static
678    {
679        foreach ($this->items as $key => $item) {
680            if ($callback($item, $key) === false) {
681                break;
682            }
683        }
684        return $this;
685    }
686
687    /**
688     * Get the first item, optionally the first matching a callback.
689     *
690     * Returns the given default (or null) when nothing matches.
691     *
692     * @template  TDefault
693     * @param  (callable(TValue, TKey): bool)|null $callback
694     * @param  TDefault $default
695     * @return  TValue|TDefault
696     */
697    public function first(?callable $callback = null, mixed $default = null): mixed
698    {
699        if ($callback === null) {
700            $key = array_key_first($this->items);
701            return $key === null ? $default : $this->items[$key];
702        }
703        foreach ($this->items as $key => $item) {
704            if ($callback($item, $key)) {
705                return $item;
706            }
707        }
708        return $default;
709    }
710
711    /**
712     * Get the last item, optionally the last matching a callback.
713     *
714     * Returns the given default (or null) when nothing matches.
715     *
716     * @template  TDefault
717     * @param  (callable(TValue, TKey): bool)|null $callback
718     * @param  TDefault $default
719     * @return  TValue|TDefault
720     */
721    public function last(?callable $callback = null, mixed $default = null): mixed
722    {
723        if ($callback === null) {
724            $key = array_key_last($this->items);
725            return $key === null ? $default : $this->items[$key];
726        }
727        foreach (array_reverse(array_keys($this->items)) as $key) {
728            if ($callback($this->items[$key], $key)) {
729                return $this->items[$key];
730            }
731        }
732        return $default;
733    }
734
735    /**
736     * Reset the collection's keys to a sequential 0-based list.
737     *
738     * @return  static
739     */
740    public function values(): static
741    {
742        return new static(array_values($this->items));
743    }
744
745    /**
746     * Get the collection's keys as a new collection.
747     *
748     * @return  static
749     */
750    public function keys(): static
751    {
752        return new static(array_keys($this->items));
753    }
754
755    // ---- Selection ----
756
757    /**
758     * Take the first N items.
759     *
760     * Mirrors `array_slice($items, 0, $limit, true)`: a negative $limit omits
761     * the last |$limit| items. Original keys are always preserved â€” call
762     * `values()` to renumber to a 0-based list.
763     *
764     * @param  int $limit
765     * @return  static
766     */
767    public function take(int $limit): static
768    {
769        return new static(array_slice($this->items, 0, $limit, true));
770    }
771
772    /**
773     * Skip the first N items.
774     *
775     * Mirrors `array_slice($items, $count, null, true)`: a negative $count
776     * keeps only the last |$count| items. Original keys are always preserved
777     * â€” call `values()` to renumber to a 0-based list.
778     *
779     * @param  int $count
780     * @return  static
781     */
782    public function skip(int $count): static
783    {
784        return new static(array_slice($this->items, $count, null, true));
785    }
786
787    /**
788     * Take a slice of the collection starting at the given offset.
789     *
790     * Mirrors `array_slice($items, $offset, $length, true)`: a negative
791     * $offset counts from the end of the collection and a negative $length
792     * stops that many items before the end. Original keys are always
793     * preserved â€” call `values()` to renumber to a 0-based list.
794     *
795     * @param  int $offset
796     * @param  int|null $length
797     * @return  static
798     */
799    public function slice(int $offset, ?int $length = null): static
800    {
801        return new static(array_slice($this->items, $offset, $length, true));
802    }
803
804    /**
805     * Take items while the callback passes, stopping at the first failure.
806     *
807     * Keys are preserved.
808     *
809     * @param  callable(TValue, TKey): bool $callback
810     * @return  static
811     */
812    public function takeWhile(callable $callback): static
813    {
814        $results = [];
815        foreach ($this->items as $key => $item) {
816            if (!$callback($item, $key)) {
817                break;
818            }
819            $results[$key] = $item;
820        }
821        return new static($results);
822    }
823
824    /**
825     * Skip items while the callback passes, keeping the rest.
826     *
827     * Only the initial run of passing items is skipped â€” after the first
828     * failure, every remaining item is kept even if the callback passes
829     * again. Keys are preserved.
830     *
831     * @param  callable(TValue, TKey): bool $callback
832     * @return  static
833     */
834    public function skipWhile(callable $callback): static
835    {
836        $results = [];
837        $failed = false;
838        foreach ($this->items as $key => $item) {
839            $failed = $failed || !$callback($item, $key);
840            if ($failed) {
841                $results[$key] = $item;
842            }
843        }
844        return new static($results);
845    }
846
847    /**
848     * Take items until the callback passes, stopping at the first success.
849     *
850     * Keys are preserved.
851     *
852     * @param  callable(TValue, TKey): bool $callback
853     * @return  static
854     */
855    public function takeUntil(callable $callback): static
856    {
857        $results = [];
858        foreach ($this->items as $key => $item) {
859            if ($callback($item, $key)) {
860                break;
861            }
862            $results[$key] = $item;
863        }
864        return new static($results);
865    }
866
867    /**
868     * Skip items until the callback passes, keeping the rest.
869     *
870     * The item on which the callback first passes is the first kept item.
871     * Keys are preserved.
872     *
873     * @param  callable(TValue, TKey): bool $callback
874     * @return  static
875     */
876    public function skipUntil(callable $callback): static
877    {
878        $results = [];
879        $passed = false;
880        foreach ($this->items as $key => $item) {
881            $passed = $passed || $callback($item, $key);
882            if ($passed) {
883                $results[$key] = $item;
884            }
885        }
886        return new static($results);
887    }
888
889    /**
890     * Remove duplicate items from the collection.
891     *
892     * When a key is given, items are deduplicated by that column's value
893     * (keeping the first occurrence). When strict is true, values are
894     * compared with type checking.
895     *
896     * Note: when no key is given, dedup is always strict (type-aware) and
897     * the $strict flag is ignored â€” loose dedup of raw values would silently
898     * merge `1` and `'1'`. The flag only applies to column-based dedup.
899     *
900     * @param  string|null $key
901     * @param  bool $strict
902     * @return  static
903     */
904    public function unique(?string $key = null, bool $strict = false): static
905    {
906        if ($key === null) {
907            $seen = [];
908            $result = [];
909            foreach ($this->items as $k => $item) {
910                if (!$this->isSeen($item, $seen, true)) {
911                    $result[$k] = $item;
912                }
913            }
914            return new static($result);
915        }
916        $seen = [];
917        return $this->filter(function ($item) use ($key, $strict, &$seen) {
918            return !$this->isSeen($this->value($item, $key), $seen, $strict);
919        });
920    }
921
922    /**
923     * Determine whether a value has already been seen, tracking it if not.
924     *
925     * Scalars hash by their string form (type-prefixed in strict mode),
926     * arrays by a hash of their serialization, and objects by identity.
927     *
928     * @param  mixed $value
929     * @param  array<int|string, mixed> $seen
930     * @param  bool $strict
931     * @return  bool
932     */
933    protected function isSeen(mixed $value, array &$seen, bool $strict): bool
934    {
935        if (is_scalar($value) || $value === null) {
936            $key = $strict
937                ? get_debug_type($value) . ':' . (string) $value
938                : (string) $value;
939        } elseif (is_object($value)) {
940            $key = 'o' . spl_object_id($value);
941        } else {
942            // Arrays (and any other non-scalar): hash the serialization.
943            // serialize() preserves order and types, so equal arrays hash
944            // equally â€” a close approximation of loose == for arrays.
945            $key = 'a' . md5(serialize($value));
946        }
947
948        if (array_key_exists($key, $seen)) {
949            return true;
950        }
951        $seen[$key] = true;
952        return false;
953    }
954
955    /**
956     * Sort the collection, optionally with a custom comparator.
957     *
958     * Keys are preserved. Without a callback, items are sorted by value
959     * using `asort`.
960     *
961     * @param  (callable(TValue, TValue): int)|null $callback
962     * @return  static
963     */
964    public function sort(?callable $callback = null): static
965    {
966        $items = $this->items;
967        $callback ? uasort($items, $callback) : asort($items);
968        return new static($items);
969    }
970
971    /**
972     * Sort the collection by a column or callback.
973     *
974     * Keys are preserved. Set descending to true for reverse order. The
975     * $options flag is passed through to the underlying comparison:
976     *  - SORT_NUMERIC â€” numeric comparison
977     *  - SORT_STRING  â€” lexical (string) comparison
978     *  - SORT_REGULAR (default) â€” numeric-aware when both values are numeric,
979     *    otherwise lexical
980     *
981     * @param  string|(callable(TValue): mixed) $column
982     * @param  int $options
983     * @param  bool $descending
984     * @return  static
985     */
986    public function sortBy(string|callable $column, int $options = SORT_REGULAR, bool $descending = false): static
987    {
988        $callback = is_callable($column) ? $column : fn ($item) => $this->value($item, $column);
989
990        // Decorate: [sortKey, originalKey, item].
991        $decorated = [];
992        foreach ($this->items as $key => $item) {
993            $decorated[] = [$callback($item), $key, $item];
994        }
995
996        usort($decorated, function ($a, $b) use ($options, $descending) {
997            $aVal = $a[0];
998            $bVal = $b[0];
999            $cmp = match ($options) {
1000                SORT_NUMERIC => $aVal <=> $bVal,
1001                SORT_STRING => strcmp((string) $aVal, (string) $bVal),
1002                default => is_numeric($aVal) && is_numeric($bVal)
1003                    ? $aVal <=> $bVal
1004                    : strcmp((string) $aVal, (string) $bVal),
1005            };
1006            if ($cmp !== 0) {
1007                return $descending ? -$cmp : $cmp;
1008            }
1009            // Stable tie-break on the original key so equal sort keys keep
1010            // their original relative order (uasort/usort are not stable).
1011            return $a[1] <=> $b[1];
1012        });
1013
1014        // Undecorate, preserving original keys.
1015        $results = [];
1016        foreach ($decorated as [, $key, $item]) {
1017            $results[$key] = $item;
1018        }
1019        return new static($results);
1020    }
1021
1022    /**
1023     * Reverse the order of the collection's items.
1024     *
1025     * Keys are preserved.
1026     *
1027     * @return  static
1028     */
1029    public function reverse(): static
1030    {
1031        return new static(array_reverse($this->items, true));
1032    }
1033
1034    // ---- Conversion ----
1035
1036    /**
1037     * Convert the collection to a plain array.
1038     *
1039     * Nested collections are recursively converted to arrays.
1040     *
1041     * @return  array<TKey, mixed>
1042     */
1043    public function toArray(): array
1044    {
1045        return array_map(
1046            fn ($value) => $value instanceof Enumerable ? $value->toArray() : $value,
1047            $this->items
1048        );
1049    }
1050
1051    /**
1052     * Convert the collection to a JSON string.
1053     *
1054     * @param  int $options
1055     * @return  string
1056     */
1057    public function toJson(int $options = 0): string
1058    {
1059        return json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR);
1060    }
1061
1062    /**
1063     * Serialize the collection to a JSON-encodable array.
1064     *
1065     * @return  array<TKey, mixed>
1066     */
1067    public function jsonSerialize(): array
1068    {
1069        return $this->toArray();
1070    }
1071
1072    /**
1073     * Get the underlying items array.
1074     *
1075     * Unlike toArray, nested collections are not recursively converted.
1076     *
1077     * @return  array<TKey, TValue>
1078     */
1079    public function all(): array
1080    {
1081        return $this->items;
1082    }
1083
1084    // ---- ArrayAccess / Countable / IteratorAggregate ----
1085
1086    /**
1087     * Determine whether an item exists at the given offset.
1088     *
1089     * Uses array_key_exists so that an item whose value is null still counts
1090     * as present (isset() would report it as absent).
1091     *
1092     * @param  mixed $offset
1093     * @return  bool
1094     */
1095    public function offsetExists(mixed $offset): bool
1096    {
1097        return array_key_exists($offset, $this->items);
1098    }
1099
1100    /**
1101     * Get the item at the given offset, or null if it does not exist.
1102     *
1103     * @param  mixed $offset
1104     * @return  TValue|null
1105     */
1106    public function offsetGet(mixed $offset): mixed
1107    {
1108        return $this->items[$offset] ?? null;
1109    }
1110
1111    /**
1112     * Writing to a collection is not supported.
1113     *
1114     * @param  mixed  $offset
1115     * @param  mixed  $value
1116     * @return  void
1117     * @throws  \BadMethodCallException
1118     */
1119    public function offsetSet(mixed $offset, mixed $value): void
1120    {
1121        throw new \BadMethodCallException('Collection is immutable; use a transform instead.');
1122    }
1123
1124    /**
1125     * Removing an item from a collection is not supported.
1126     *
1127     * @param  mixed  $offset
1128     * @return  void
1129     * @throws  \BadMethodCallException
1130     */
1131    public function offsetUnset(mixed $offset): void
1132    {
1133        throw new \BadMethodCallException('Collection is immutable; use a transform instead.');
1134    }
1135
1136    /**
1137     * Get an iterator for the collection's items.
1138     *
1139     * @return  \ArrayIterator<TKey, TValue>
1140     */
1141    public function getIterator(): \Traversable
1142    {
1143        return new \ArrayIterator($this->items);
1144    }
1145}