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