1<?php declare(strict_types=1);
2
3namespace DOMWrap\Traits;
4
5use DOMWrap\{
6    Element,
7    NodeList
8};
9use Symfony\Component\CssSelector\CssSelectorConverter;
10
11/**
12 * Traversal Trait
13 *
14 * @package DOMWrap\Traits
15 * @license http://opensource.org/licenses/BSD-3-Clause BSD 3 Clause
16 */
17trait TraversalTrait
18{
19    protected static $cssSelectorConverter;
20
21    /**
22     * @param iterable $nodes
23     *
24     * @return NodeList
25     */
26    public function newNodeList(iterable $nodes = null): NodeList {
27
28        if (!is_iterable($nodes)) {
29            if (!is_null($nodes)) {
30                $nodes = [$nodes];
31            } else {
32                $nodes = [];
33            }
34        }
35
36        return new NodeList($this->document(), $nodes);
37    }
38
39    /**
40     * @param string $selector
41     * @param string $prefix
42     *
43     * @return NodeList
44     */
45    public function find(string $selector, string $prefix = 'descendant::'): NodeList {
46        if (!self::$cssSelectorConverter) {
47            self::$cssSelectorConverter = new CssSelectorConverter();
48        }
49
50        return $this->findXPath(self::$cssSelectorConverter->toXPath($selector, $prefix));
51    }
52
53    /**
54     * @param string $xpath
55     *
56     * @return NodeList
57     */
58    public function findXPath(string $xpath): NodeList {
59        $results = $this->newNodeList();
60
61        if ($this->isRemoved()) {
62            return $results;
63        }
64
65        $domxpath = new \DOMXPath($this->document());
66
67        foreach ($this->collection() as $node) {
68            $results = $results->merge(
69                $node->newNodeList($domxpath->query($xpath, $node))
70            );
71        }
72
73        return $results;
74    }
75
76    /**
77     * @param string|NodeList|\DOMNode|callable $input
78     * @param bool $matchType
79     *
80     * @return NodeList
81     */
82    protected function getNodesMatchingInput($input, bool $matchType = true): NodeList {
83        if ($input instanceof NodeList || $input instanceof \DOMNode) {
84            $inputNodes = $this->inputAsNodeList($input, false);
85
86            $fn = function($node) use ($inputNodes) {
87                return $inputNodes->exists($node);
88            };
89
90
91        } elseif (is_callable($input)) {
92            // Since we're at the behest of the input callable, the 'matched'
93            //  return value is always true.
94            $matchType = true;
95
96            $fn = $input;
97
98        } elseif (is_string($input)) {
99            $fn = function($node) use ($input) {
100                return $node->find($input, 'self::')->count() != 0;
101            };
102
103        } else {
104            throw new \InvalidArgumentException('Unexpected input value of type "' . gettype($input) . '"');
105        }
106
107        // Build a list of matching nodes.
108        return $this->collection()->map(function($node) use ($fn, $matchType) {
109            if ($fn($node) !== $matchType) {
110                return null;
111            }
112
113            return $node;
114        });
115    }
116
117    /**
118     * @param string|NodeList|\DOMNode|callable $input
119     *
120     * @return bool
121     */
122    public function is($input): bool {
123        return $this->getNodesMatchingInput($input)->count() != 0;
124    }
125
126    /**
127     * @param string|NodeList|\DOMNode|callable $input
128     *
129     * @return NodeList
130     */
131    public function not($input): NodeList {
132        return $this->getNodesMatchingInput($input, false);
133    }
134
135    /**
136     * @param string|NodeList|\DOMNode|callable $input
137     *
138     * @return NodeList
139     */
140    public function filter($input): NodeList {
141        return $this->getNodesMatchingInput($input);
142    }
143
144    /**
145     * @param string|NodeList|\DOMNode|callable $input
146     *
147     * @return NodeList
148     */
149    public function has($input): NodeList {
150        if ($input instanceof NodeList || $input instanceof \DOMNode) {
151            $inputNodes = $this->inputAsNodeList($input, false);
152
153            $fn = function($node) use ($inputNodes) {
154                $descendantNodes = $node->find('*', 'descendant::');
155
156                // Determine if we have a descendant match.
157                return $inputNodes->reduce(function($carry, $inputNode) use ($descendantNodes) {
158                    // Match descendant nodes against input nodes.
159                    if ($descendantNodes->exists($inputNode)) {
160                        return true;
161                    }
162
163                    return $carry;
164                }, false);
165            };
166
167        } elseif (is_string($input)) {
168            $fn = function($node) use ($input) {
169                return $node->find($input, 'descendant::')->count() != 0;
170            };
171
172        } elseif (is_callable($input)) {
173            $fn = $input;
174
175        } else {
176            throw new \InvalidArgumentException('Unexpected input value of type "' . gettype($input) . '"');
177        }
178
179        return $this->getNodesMatchingInput($fn);
180    }
181
182    /**
183     * @param string|NodeList|\DOMNode|callable $selector
184     *
185     * @return \DOMNode|null
186     */
187    public function preceding($selector = null): ?\DOMNode {
188        return $this->precedingUntil(null, $selector)->first();
189    }
190
191    /**
192     * @param string|NodeList|\DOMNode|callable $selector
193     *
194     * @return NodeList
195     */
196    public function precedingAll($selector = null): NodeList {
197        return $this->precedingUntil(null, $selector);
198    }
199
200    /**
201     * @param string|NodeList|\DOMNode|callable $input
202     * @param string|NodeList|\DOMNode|callable $selector
203     *
204     * @return NodeList
205     */
206    public function precedingUntil($input = null, $selector = null): NodeList {
207        return $this->_walkPathUntil('previousSibling', $input, $selector);
208    }
209
210    /**
211     * @param string|NodeList|\DOMNode|callable $selector
212     *
213     * @return \DOMNode|null
214     */
215    public function following($selector = null): ?\DOMNode {
216        return $this->followingUntil(null, $selector)->first();
217    }
218
219    /**
220     * @param string|NodeList|\DOMNode|callable $selector
221     *
222     * @return NodeList
223     */
224    public function followingAll($selector = null): NodeList {
225        return $this->followingUntil(null, $selector);
226    }
227
228    /**
229     * @param string|NodeList|\DOMNode|callable $input
230     * @param string|NodeList|\DOMNode|callable $selector
231     *
232     * @return NodeList
233     */
234    public function followingUntil($input = null, $selector = null): NodeList {
235        return $this->_walkPathUntil('nextSibling', $input, $selector);
236    }
237
238    /**
239     * @param string|NodeList|\DOMNode|callable $selector
240     *
241     * @return NodeList
242     */
243    public function siblings($selector = null): NodeList {
244        $results = $this->collection()->reduce(function($carry, $node) use ($selector) {
245            return $carry->merge(
246                $node->precedingAll($selector)->merge(
247                    $node->followingAll($selector)
248                )
249            );
250        }, $this->newNodeList());
251
252        return $results;
253    }
254
255    /**
256     * NodeList is only array like. Removing items using foreach() has undesired results.
257     *
258     * @return NodeList
259     */
260    public function children(): NodeList {
261        $results = $this->collection()->reduce(function($carry, $node) {
262            return $carry->merge(
263                $node->findXPath('child::*')
264            );
265        }, $this->newNodeList());
266
267        return $results;
268    }
269
270    /**
271     * @param string|NodeList|\DOMNode|callable $selector
272     *
273     * @return Element|NodeList|null
274     */
275    public function parent($selector = null) {
276        $results = $this->_walkPathUntil('parentNode', null, $selector, self::$MATCH_TYPE_FIRST);
277
278        return $this->result($results);
279    }
280
281    /**
282     * @param int $index
283     *
284     * @return \DOMNode|null
285     */
286    public function eq(int $index): ?\DOMNode {
287        if ($index < 0) {
288            $index = $this->collection()->count() + $index;
289        }
290
291        return $this->collection()->offsetGet($index);
292    }
293
294    /**
295     * @param string $selector
296     *
297     * @return NodeList
298     */
299    public function parents(string $selector = null): NodeList {
300        return $this->parentsUntil(null, $selector);
301    }
302
303    /**
304     * @param string|NodeList|\DOMNode|callable $input
305     * @param string|NodeList|\DOMNode|callable $selector
306     *
307     * @return NodeList
308     */
309    public function parentsUntil($input = null, $selector = null): NodeList {
310        return $this->_walkPathUntil('parentNode', $input, $selector);
311    }
312
313    /**
314     * @return \DOMNode
315     */
316    public function intersect(): \DOMNode {
317        if ($this->collection()->count() < 2) {
318            return $this->collection()->first();
319        }
320
321        $nodeParents = [];
322
323        // Build a multi-dimensional array of the collection nodes parent elements
324        $this->collection()->each(function($node) use(&$nodeParents) {
325            $nodeParents[] = $node->parents()->unshift($node)->toArray();
326        });
327
328        // Find the common parent
329        $diff = call_user_func_array('array_uintersect', array_merge($nodeParents, [function($a, $b) {
330            return strcmp(spl_object_hash($a), spl_object_hash($b));
331        }]));
332
333        return array_shift($diff);
334    }
335
336    /**
337     * @param string|NodeList|\DOMNode|callable $input
338     *
339     * @return Element|NodeList|null
340     */
341    public function closest($input) {
342        $results = $this->_walkPathUntil('parentNode', $input, null, self::$MATCH_TYPE_LAST);
343
344        return $this->result($results);
345    }
346
347    /**
348     * NodeList is only array like. Removing items using foreach() has undesired results.
349     *
350     * @return NodeList
351     */
352    public function contents(): NodeList {
353        $results = $this->collection()->reduce(function($carry, $node) {
354            if ($node->isRemoved()) {
355                return $carry;
356            }
357
358            return $carry->merge(
359                $node->newNodeList($node->childNodes)
360            );
361        }, $this->newNodeList());
362
363        return $results;
364    }
365
366    /**
367     * @param string|NodeList|\DOMNode $input
368     *
369     * @return NodeList
370     */
371    public function add($input): NodeList {
372        $nodes = $this->inputAsNodeList($input);
373
374        $results = $this->collection()->merge(
375            $nodes
376        );
377
378        return $results;
379    }
380
381    /** @var int */
382    private static $MATCH_TYPE_FIRST = 1;
383
384    /** @var int */
385    private static $MATCH_TYPE_LAST = 2;
386
387    /**
388     * @param \DOMNode $baseNode
389     * @param string $property
390     * @param string|NodeList|\DOMNode|callable $input
391     * @param string|NodeList|\DOMNode|callable $selector
392     * @param int $matchType
393     *
394     * @return NodeList
395     */
396    protected function _buildNodeListUntil(\DOMNode $baseNode, string $property, $input = null, $selector = null, int $matchType = null): NodeList {
397        $resultNodes = $this->newNodeList();
398
399        // Get our first node
400        $node = $baseNode->$property;
401
402        // Keep looping until we are out of nodes.
403        // Allow either FIRST to reach \DOMDocument. Others that return multiple should ignore it.
404        while ($node instanceof \DOMNode && ($matchType === self::$MATCH_TYPE_FIRST || !($node instanceof \DOMDocument))) {
405            // Filter nodes if not matching last
406            if ($matchType != self::$MATCH_TYPE_LAST && (is_null($selector) || $node->is($selector))) {
407                $resultNodes[] = $node;
408            }
409
410            // 'Until' check or first match only
411            if ($matchType == self::$MATCH_TYPE_FIRST || (!is_null($input) && $node->is($input))) {
412                // Set last match
413                if ($matchType == self::$MATCH_TYPE_LAST) {
414                    $resultNodes[] = $node;
415                }
416
417                break;
418            }
419
420            // Find the next node
421            $node = $node->{$property};
422        }
423
424        return $resultNodes;
425    }
426
427    /**
428     * @param iterable $nodeLists
429     *
430     * @return NodeList
431     */
432    protected function _uniqueNodes(iterable $nodeLists): NodeList {
433        $resultNodes = $this->newNodeList();
434
435        // Loop through our array of NodeLists
436        foreach ($nodeLists as $nodeList) {
437            // Each node in the NodeList
438            foreach ($nodeList as $node) {
439                // We're only interested in unique nodes
440                if (!$resultNodes->exists($node)) {
441                    $resultNodes[] = $node;
442                }
443            }
444        }
445
446        // Sort resulting NodeList: outer-most => inner-most.
447        return $resultNodes->reverse();
448    }
449
450    /**
451     * @param string $property
452     * @param string|NodeList|\DOMNode|callable $input
453     * @param string|NodeList|\DOMNode|callable $selector
454     * @param int $matchType
455     *
456     * @return NodeList
457     */
458    protected function _walkPathUntil(string $property, $input = null, $selector = null, int $matchType = null): NodeList {
459        $nodeLists = [];
460
461        $this->collection()->each(function($node) use($property, $input, $selector, $matchType, &$nodeLists) {
462            $nodeLists[] = $this->_buildNodeListUntil($node, $property, $input, $selector, $matchType);
463        });
464
465        return $this->_uniqueNodes($nodeLists);
466    }
467}