1<?php declare(strict_types=1);
2
3namespace DOMWrap\Traits;
4
5use DOMWrap\{
6    Text,
7    Element,
8    NodeList
9};
10
11/**
12 * Manipulation Trait
13 *
14 * @package DOMWrap\Traits
15 * @license http://opensource.org/licenses/BSD-3-Clause BSD 3 Clause
16 */
17trait ManipulationTrait
18{
19    /**
20     * Magic method - Trap function names using reserved keyword (empty, clone, etc..)
21     *
22     * @param string $name
23     * @param array $arguments
24     *
25     * @return mixed
26     */
27    public function __call(string $name, array $arguments) {
28        if (!method_exists($this, '_' . $name)) {
29            throw new \BadMethodCallException("Call to undefined method " . get_class($this) . '::' . $name . "()");
30        }
31
32        return call_user_func_array([$this, '_' . $name], $arguments);
33    }
34
35    /**
36     * @return string
37     */
38    public function __toString(): string {
39        return $this->getOuterHtml(true);
40    }
41
42    /**
43     * @param string|NodeList|\DOMNode $input
44     *
45     * @return iterable
46     */
47    protected function inputPrepareAsTraversable($input): iterable {
48        if ($input instanceof \DOMNode) {
49            // Handle raw \DOMNode elements and 'convert' them into their DOMWrap/* counterpart
50            if (!method_exists($input, 'inputPrepareAsTraversable')) {
51                $input = $this->document()->importNode($input, true);
52            }
53
54            $nodes = [$input];
55        } else if (is_string($input)) {
56            $nodes = $this->nodesFromHtml($input);
57        } else if (is_iterable($input)) {
58            $nodes = $input;
59        } else {
60            throw new \InvalidArgumentException();
61        }
62
63        return $nodes;
64    }
65
66    /**
67     * @param string|NodeList|\DOMNode $input
68     * @param bool $cloneForManipulate
69     *
70     * @return NodeList
71     */
72    protected function inputAsNodeList($input, $cloneForManipulate = true): NodeList {
73        $nodes = $this->inputPrepareAsTraversable($input);
74
75        $newNodes = $this->newNodeList();
76
77        foreach ($nodes as $node) {
78            if ($node->document() !== $this->document()) {
79                 $node = $this->document()->importNode($node, true);
80            }
81
82            if ($cloneForManipulate && $node->parentNode !== null) {
83                $node = $node->cloneNode(true);
84            }
85
86            $newNodes[] = $node;
87        }
88
89        return $newNodes;
90    }
91
92    /**
93     * @param string|NodeList|\DOMNode $input
94     *
95     * @return \DOMNode|null
96     */
97    protected function inputAsFirstNode($input): ?\DOMNode {
98        $nodes = $this->inputAsNodeList($input);
99
100        return $nodes->findXPath('self::*')->first();
101    }
102
103    /**
104     * @param string $html
105     *
106     * @return NodeList
107     */
108    protected function nodesFromHtml($html): NodeList {
109        $class = get_class($this->document());
110        $doc = new $class();
111        $doc->setEncoding($this->document()->getEncoding());
112        $nodes = $doc->html($html)->find('body')->contents();
113
114        return $nodes;
115    }
116
117    /**
118     * @param string|NodeList|\DOMNode|callable $input
119     * @param callable $callback
120     *
121     * @return self
122     */
123    protected function manipulateNodesWithInput($input, callable $callback): self {
124        $this->collection()->each(function($node, $index) use ($input, $callback) {
125            $html = $input;
126
127            /*if ($input instanceof \DOMNode) {
128                if ($input->parentNode !== null) {
129                    $html = $input->cloneNode(true);
130                }
131            } else*/if (is_callable($input)) {
132                $html = $input($node, $index);
133            }
134
135            $newNodes = $this->inputAsNodeList($html);
136
137            $callback($node, $newNodes);
138        });
139
140        return $this;
141    }
142
143    /**
144     * @param string|null $selector
145     *
146     * @return NodeList
147     */
148    public function detach(string $selector = null): NodeList {
149        if (!is_null($selector)) {
150            $nodes = $this->find($selector, 'self::');
151        } else {
152            $nodes = $this->collection();
153        }
154
155        $nodeList = $this->newNodeList();
156
157        $nodes->each(function($node) use($nodeList) {
158            if ($node->parent() instanceof \DOMNode) {
159                $nodeList[] = $node->parent()->removeChild($node);
160            }
161        });
162
163        $nodes->fromArray([]);
164
165        return $nodeList;
166    }
167
168    /**
169     * @param string|null $selector
170     *
171     * @return self
172     */
173    public function destroy(string $selector = null): self {
174        $this->detach($selector);
175
176        return $this;
177    }
178
179    /**
180     * @param string|NodeList|\DOMNode|callable $input
181     *
182     * @return self
183     */
184    public function substituteWith($input): self {
185        $this->manipulateNodesWithInput($input, function($node, $newNodes) {
186            foreach ($newNodes as $newNode) {
187                $node->parent()->replaceChild($newNode, $node);
188            }
189        });
190
191        return $this;
192    }
193
194    /**
195     * @param string|NodeList|\DOMNode|callable $input
196     *
197     * @return string|self
198     */
199    public function text($input = null) {
200        if (is_null($input)) {
201            return $this->getText();
202        } else {
203            return $this->setText($input);
204        }
205    }
206
207    /**
208     * @return string
209     */
210    public function getText(): string {
211        return (string)$this->collection()->reduce(function($carry, $node) {
212            return $carry . $node->textContent;
213        }, '');
214    }
215
216    /**
217     * @param string|NodeList|\DOMNode|callable $input
218     *
219     * @return self
220     */
221    public function setText($input): self {
222        if (is_string($input)) {
223            $input = new Text($input);
224        }
225
226        $this->manipulateNodesWithInput($input, function($node, $newNodes) {
227            // Remove old contents from the current node.
228            $node->contents()->destroy();
229
230            // Add new contents in it's place.
231            $node->appendWith(new Text($newNodes->getText()));
232        });
233
234        return $this;
235    }
236
237    /**
238     * @param string|NodeList|\DOMNode|callable $input
239     *
240     * @return self
241     */
242    public function precede($input): self {
243        $this->manipulateNodesWithInput($input, function($node, $newNodes) {
244            foreach ($newNodes as $newNode) {
245                $node->parent()->insertBefore($newNode, $node);
246            }
247        });
248
249        return $this;
250    }
251
252    /**
253     * @param string|NodeList|\DOMNode|callable $input
254     *
255     * @return self
256     */
257    public function follow($input): self {
258        $this->manipulateNodesWithInput($input, function($node, $newNodes) {
259            foreach ($newNodes as $newNode) {
260                if (is_null($node->following())) {
261                    $node->parent()->appendChild($newNode);
262                } else {
263                    $node->parent()->insertBefore($newNode, $node->following());
264                }
265            }
266        });
267
268        return $this;
269    }
270
271    /**
272     * @param string|NodeList|\DOMNode|callable $input
273     *
274     * @return self
275     */
276    public function prependWith($input): self {
277        $this->manipulateNodesWithInput($input, function($node, $newNodes) {
278            foreach ($newNodes as $newNode) {
279                $node->insertBefore($newNode, $node->contents()->first());
280            }
281        });
282
283        return $this;
284    }
285
286    /**
287     * @param string|NodeList|\DOMNode|callable $input
288     *
289     * @return self
290     */
291    public function appendWith($input): self {
292        $this->manipulateNodesWithInput($input, function($node, $newNodes) {
293            foreach ($newNodes as $newNode) {
294                $node->appendChild($newNode);
295            }
296        });
297
298        return $this;
299    }
300
301    /**
302     * @param string|NodeList|\DOMNode $selector
303     *
304     * @return self
305     */
306    public function prependTo($selector): self {
307        if ($selector instanceof \DOMNode || $selector instanceof NodeList) {
308            $nodes = $this->inputAsNodeList($selector);
309        } else {
310            $nodes = $this->document()->find($selector);
311        }
312
313        $nodes->prependWith($this);
314
315        return $this;
316    }
317
318    /**
319     * @param string|NodeList|\DOMNode $selector
320     *
321     * @return self
322     */
323    public function appendTo($selector): self {
324        if ($selector instanceof \DOMNode || $selector instanceof NodeList) {
325            $nodes = $this->inputAsNodeList($selector);
326        } else {
327            $nodes = $this->document()->find($selector);
328        }
329
330        $nodes->appendWith($this);
331
332        return $this;
333    }
334
335    /**
336     * @return self
337     */
338    public function _empty(): self {
339        $this->collection()->each(function($node) {
340            $node->contents()->destroy();
341        });
342
343        return $this;
344    }
345
346    /**
347     * @return NodeList|\DOMNode
348     */
349    public function _clone() {
350        $clonedNodes = $this->newNodeList();
351
352        $this->collection()->each(function($node) use($clonedNodes) {
353            $clonedNodes[] = $node->cloneNode(true);
354        });
355
356        return $this->result($clonedNodes);
357    }
358
359    /**
360     * @param string $name
361     *
362     * @return self
363     */
364    public function removeAttr(string $name): self {
365        $this->collection()->each(function($node) use($name) {
366            if ($node instanceof \DOMElement) {
367                $node->removeAttribute($name);
368            }
369        });
370
371        return $this;
372    }
373
374    /**
375     * @param string $name
376     *
377     * @return bool
378     */
379    public function hasAttr(string $name): bool {
380        return (bool)$this->collection()->reduce(function($carry, $node) use ($name) {
381            if ($node->hasAttribute($name)) {
382                return true;
383            }
384
385            return $carry;
386        }, false);
387    }
388
389    /**
390     * @internal
391     *
392     * @param string $name
393     *
394     * @return string
395     */
396    public function getAttr(string $name): string {
397        $node = $this->collection()->first();
398
399        if (!($node instanceof \DOMElement)) {
400            return '';
401        }
402
403        return $node->getAttribute($name);
404    }
405
406    /**
407     * @internal
408     *
409     * @param string $name
410     * @param mixed $value
411     *
412     * @return self
413     */
414    public function setAttr(string $name, $value): self {
415        $this->collection()->each(function($node) use($name, $value) {
416            if ($node instanceof \DOMElement) {
417                $node->setAttribute($name, (string)$value);
418            }
419        });
420
421        return $this;
422    }
423
424    /**
425     * @param string $name
426     * @param mixed $value
427     *
428     * @return self|string
429     */
430    public function attr(string $name, $value = null) {
431        if (is_null($value)) {
432            return $this->getAttr($name);
433        } else {
434            return $this->setAttr($name, $value);
435        }
436    }
437
438    /**
439     * @internal
440     *
441     * @param string $name
442     * @param string|callable $value
443     * @param bool $addValue
444     */
445    protected function _pushAttrValue(string $name, $value, bool $addValue = false): void {
446        $this->collection()->each(function($node, $index) use($name, $value, $addValue) {
447            if ($node instanceof \DOMElement) {
448                $attr = $node->getAttribute($name);
449
450                if (is_callable($value)) {
451                    $value = $value($node, $index, $attr);
452                }
453
454                // Remove any existing instances of the value, or empty values.
455                $values = array_filter(explode(' ', $attr), function($_value) use($value) {
456                    if (strcasecmp($_value, $value) == 0 || empty($_value)) {
457                        return false;
458                    }
459
460                    return true;
461                });
462
463                // If required add attr value to array
464                if ($addValue) {
465                    $values[] = $value;
466                }
467
468                // Set the attr if we either have values, or the attr already
469                //  existed (we might be removing classes).
470                //
471                // Don't set the attr if it doesn't already exist.
472                if (!empty($values) || $node->hasAttribute($name)) {
473                    $node->setAttribute($name, implode(' ', $values));
474                }
475            }
476        });
477    }
478
479    /**
480     * @param string|callable $class
481     *
482     * @return self
483     */
484    public function addClass($class): self {
485        $this->_pushAttrValue('class', $class, true);
486
487        return $this;
488    }
489
490    /**
491     * @param string|callable $class
492     *
493     * @return self
494     */
495    public function removeClass($class): self {
496        $this->_pushAttrValue('class', $class);
497
498        return $this;
499    }
500
501    /**
502     * @param string $class
503     *
504     * @return bool
505     */
506    public function hasClass(string $class): bool {
507        return (bool)$this->collection()->reduce(function($carry, $node) use ($class) {
508            $attr = $node->getAttr('class');
509
510            return array_reduce(explode(' ', (string)$attr), function($carry, $item) use ($class) {
511                if (strcasecmp($item, $class) == 0) {
512                    return true;
513                }
514
515                return $carry;
516            }, false);
517        }, false);
518    }
519
520    /**
521     * @param Element $node
522     *
523     * @return \SplStack
524     */
525    protected function _getFirstChildWrapStack(Element $node): \SplStack {
526        $stack = new \SplStack;
527
528        do {
529            // Push our current node onto the stack
530            $stack->push($node);
531
532            // Get the first element child node
533            $node = $node->children()->first();
534        } while ($node instanceof Element);
535
536        // Get the top most node.
537        return $stack;
538    }
539
540    /**
541     * @param Element $node
542     *
543     * @return \SplStack
544     */
545    protected function _prepareWrapStack(Element $node): \SplStack {
546        // Generate a stack (root to leaf) of the wrapper.
547        // Includes only first element nodes / first element children.
548        $stackNodes = $this->_getFirstChildWrapStack($node);
549
550        // Only using the first element, remove any siblings.
551        foreach ($stackNodes as $stackNode) {
552            $stackNode->siblings()->destroy();
553        }
554
555        return $stackNodes;
556    }
557
558    /**
559     * @param string|NodeList|\DOMNode|callable $input
560     * @param callable $callback
561     */
562    protected function wrapWithInputByCallback($input, callable $callback): void {
563        $this->collection()->each(function($node, $index) use ($input, $callback) {
564            $html = $input;
565
566            if (is_callable($input)) {
567                $html = $input($node, $index);
568            }
569
570            $inputNode = $this->inputAsFirstNode($html);
571
572            if ($inputNode instanceof Element) {
573                // Pre-process wrapper into a stack of first element nodes.
574                $stackNodes = $this->_prepareWrapStack($inputNode);
575
576                $callback($node, $stackNodes);
577            }
578        });
579    }
580
581    /**
582     * @param string|NodeList|\DOMNode|callable $input
583     *
584     * @return self
585     */
586    public function wrapInner($input): self {
587        $this->wrapWithInputByCallback($input, function($node, $stackNodes) {
588            foreach ($node->contents() as $child) {
589                // Remove child from the current node
590                $oldChild = $child->detach()->first();
591
592                // Add it back as a child of the top (leaf) node on the stack
593                $stackNodes->top()->appendWith($oldChild);
594            }
595
596            // Add the bottom (root) node on the stack
597            $node->appendWith($stackNodes->bottom());
598        });
599
600        return $this;
601    }
602
603    /**
604     * @param string|NodeList|\DOMNode|callable $input
605     *
606     * @return self
607     */
608    public function wrap($input): self {
609        $this->wrapWithInputByCallback($input, function($node, $stackNodes) {
610            // Add the new bottom (root) node after the current node
611            $node->follow($stackNodes->bottom());
612
613            // Remove the current node
614            $oldNode = $node->detach()->first();
615
616            // Add the 'current node' back inside the new top (leaf) node.
617            $stackNodes->top()->appendWith($oldNode);
618        });
619
620        return $this;
621    }
622
623    /**
624     * @param string|NodeList|\DOMNode|callable $input
625     *
626     * @return self
627     */
628    public function wrapAll($input): self {
629        if (!$this->collection()->count()) {
630            return $this;
631        }
632
633        if (is_callable($input)) {
634            $input = $input($this->collection()->first());
635        }
636
637        $inputNode = $this->inputAsFirstNode($input);
638
639        if (!($inputNode instanceof Element)) {
640            return $this;
641        }
642
643        $stackNodes = $this->_prepareWrapStack($inputNode);
644
645        // Add the new bottom (root) node before the first matched node
646        $this->collection()->first()->precede($stackNodes->bottom());
647
648        $this->collection()->each(function($node) use ($stackNodes) {
649            // Detach and add node back inside the new wrappers top (leaf) node.
650            $stackNodes->top()->appendWith($node->detach());
651        });
652
653        return $this;
654    }
655
656    /**
657     * @return self
658     */
659    public function unwrap(): self {
660        $this->collection()->each(function($node) {
661            $parent = $node->parent();
662
663            // Replace parent node (the one we're unwrapping) with it's children.
664            $parent->contents()->each(function($childNode) use($parent) {
665                $oldChildNode = $childNode->detach()->first();
666
667                $parent->precede($oldChildNode);
668            });
669
670            $parent->destroy();
671        });
672
673        return $this;
674    }
675
676    /**
677     * @param int $isIncludeAll
678     *
679     * @return string
680     */
681    public function getOuterHtml(bool $isIncludeAll = false): string {
682        $nodes = $this->collection();
683
684        if (!$isIncludeAll) {
685            $nodes = $this->newNodeList([$nodes->first()]);
686        }
687
688        return $nodes->reduce(function($carry, $node) {
689            return $carry . $this->document()->saveHTML($node);
690        }, '');
691    }
692
693    /**
694     * @param int $isIncludeAll
695     *
696     * @return string
697     */
698    public function getHtml(bool $isIncludeAll = false): string {
699        $nodes = $this->collection();
700
701        if (!$isIncludeAll) {
702            $nodes = $this->newNodeList([$nodes->first()]);
703        }
704
705        return $nodes->contents()->reduce(function($carry, $node) {
706            return $carry . $this->document()->saveHTML($node);
707        }, '');
708    }
709
710    /**
711     * @param string|NodeList|\DOMNode|callable $input
712     *
713     * @return self
714     */
715    public function setHtml($input): self {
716        $this->manipulateNodesWithInput($input, function($node, $newNodes) {
717            // Remove old contents from the current node.
718            $node->contents()->destroy();
719
720            // Add new contents in it's place.
721            $node->appendWith($newNodes);
722        });
723
724        return $this;
725    }
726
727    /**
728     * @param string|NodeList|\DOMNode|callable $input
729     *
730     * @return string|self
731     */
732    public function html($input = null) {
733        if (is_null($input)) {
734            return $this->getHtml();
735        } else {
736            return $this->setHtml($input);
737        }
738    }
739
740    /**
741     * @param string|NodeList|\DOMNode $input
742     *
743     * @return NodeList
744     */
745    public function create($input): NodeList {
746        return $this->inputAsNodeList($input);
747    }
748}