<?php

namespace AmpProject\Tests;

/**
 * Compare HTML markup without failing on whitespace or alignment.
 *
 * @package ampproject/amp-toolbox
 */
trait MarkupComparison
{
    /**
     * Assert markup is equal.
     *
     * @param string $expected Expected markup.
     * @param string $actual   Actual markup.
     */
    protected function assertEqualMarkup($expected, $actual)
    {
        // Normalize whitespace (`foo   bar` => `foo bar`).
        $actual   = preg_replace('/\s+/', ' ', $actual);
        $expected = preg_replace('/\s+/', ' ', $expected);

        // Remove whitespace between elements (` <br> <br> ` => `<br><br>`).
        $actual   = preg_replace('/(?<=>)\s+(?=<)/', '', trim($actual));
        $expected = preg_replace('/(?<=>)\s+(?=<)/', '', trim($expected));

        // Normalize boolean attributes that use their name as value.
        $actual   = preg_replace('/(?<=\s)([a-zA-Z-_]+)="(?:\1|)"/i', '$1', $actual);
        $expected = preg_replace('/(?<=\s)([a-zA-Z-_]+)="(?:\1|)"/i', '$1', $expected);

        // Split into an array of individual elements.
        $actual   = preg_split('#(<[^>]+>|[^<>]+)#', $actual, -1, PREG_SPLIT_DELIM_CAPTURE);
        $expected = preg_split('#(<[^>]+>|[^<>]+)#', $expected, -1, PREG_SPLIT_DELIM_CAPTURE);

        $this->assertEquals(array_filter($expected), array_filter($actual));
    }

    /**
     * Assert markup is similar, disregarding differences that are inconsequential for functionality.
     *
     * @param string $expected Expected markup.
     * @param string $actual   Actual markup.
     */
    protected function assertSimilarMarkup($expected, $actual)
    {
        // Normalize whitespace (`foo   bar` => `foo bar`).
        $actual   = preg_replace('/\s+/', ' ', $actual);
        $expected = preg_replace('/\s+/', ' ', $expected);

        // Remove whitespace between elements (`<br> <br>` => `<br><br>`).
        $actual   = preg_replace('/(?<=>)\s+(?=<)/', '', trim($actual));
        $expected = preg_replace('/(?<=>)\s+(?=<)/', '', trim($expected));

        // Remove empty values (`foo=""` => `foo`).
        $actual   = preg_replace('/=([\'"]){2}/', '', $actual);
        $expected = preg_replace('/=([\'"]){2}/', '', $expected);

        // Normalize case of doctype element.
        $actual   = preg_replace('/<!doctype/i', '<!DOCTYPE', $actual);
        $expected = preg_replace('/<!doctype/i', '<!DOCTYPE', $expected);

        // Wrap all attributes in quotes (`foo=bar` => `foo="bar"`).
        $actual   = preg_replace('/(\s+[a-zA-Z-_]+)=(?!")([a-zA-Z-_.]+)/', '\1="\2"', $actual);
        $expected = preg_replace('/(\s+[a-zA-Z-_]+)=(?!")([a-zA-Z-_.]+)/', '\1="\2"', $expected);

        // Normalize empty JSON objects (`<script> { } </script>` => `<script>{}</script>`).
        $actual   = preg_replace('/>\s*{\s*}\s*</', '>{}<', $actual);
        $expected = preg_replace('/>\s*{\s*}\s*</', '>{}<', $expected);

        // Normalize boolean attributes that use their name as value.
        $actual   = preg_replace('/(?<=\s)([a-zA-Z-_]+)="(?:\1|)"/i', '$1', $actual);
        $expected = preg_replace('/(?<=\s)([a-zA-Z-_]+)="(?:\1|)"/i', '$1', $expected);

        // Normalize inline styles to always end with a semicolon.
        $actual   = preg_replace('/\s+style="([^"]+?)(?:;)?"/i', ' style="$1;"', $actual);
        $expected = preg_replace('/\s+style="([^"]+?)(?:;)?"/i', ' style="$1;"', $expected);

        // Decode special HTML characters (`&amp; => &`).
        $actual   = htmlspecialchars_decode($actual, ENT_COMPAT | ENT_HTML5);
        $expected = htmlspecialchars_decode($expected, ENT_COMPAT | ENT_HTML5);

        $lowercase = static function ($matches) {
            return strtolower($matches[1]);
        };

        // Fix case-inconsistency for inlined SVGs.
        // See https://github.com/ampproject/amp-toolbox-php/issues/107.
        $actual   = preg_replace_callback('/(?<=\s)(maskUnits|viewBox)(?==)/', $lowercase, $actual);
        $expected = preg_replace_callback('/(?<=\s)(maskUnits|viewBox)(?==)/', $lowercase, $expected);

        $normalizeAttributes = static function ($element) {
            // Extract attributes for the given element.
            if (!preg_match('#^(<[a-z0-9-]+)(\s[^>]+)>$#i', $element, $matches)) {
                return $element;
            }

            // Split into individual attributes.
            $attributes = array_map(
                'trim',
                array_filter(
                    preg_split(
                        '#(\s+[^"\'\s=]+(?:=(?:"[^"]+"|\'[^\']+\'|[^"\'\s]+))?)#',
                        $matches[2],
                        -1,
                        PREG_SPLIT_DELIM_CAPTURE
                    ),
                    static function ($match) {
                        // Eliminate individual slash as attribute, which is a left-over from self-closing tags.
                        if (trim($match) === '/') {
                            return false;
                        }

                        return (bool) $match;
                    }
                )
            );

            // Normalize sort order.
            sort($attributes);

            $attributeString = implode(' ', $attributes);

            if (!empty($attributeString)) {
                $attributeString = " {$attributeString}";
            }

            return "{$matches[1]}{$attributeString}>";
        };

        // Split into an array of individual elements.
        $actual   = preg_split('#((?><!--.*?-->)|<(?>"[^"]+"|\'[^\']+\'|[^"\'>]+)+>|(?>[^<>]+))#', $actual, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
        $expected = preg_split('#((?><!--.*?-->)|<(?>"[^"]+"|\'[^\']+\'|[^"\'>]+)+>|(?>[^<>]+))#', $expected, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);

        // Normalize the attributes for each individual element.
        $actual   = array_map($normalizeAttributes, array_filter($actual));
        $expected = array_map($normalizeAttributes, array_filter($expected));

        $replacePipes = static function ($attribute) {
            return str_replace('%7C', '|', $attribute);
        };

        $actual   = array_map($replacePipes, $actual);
        $expected = array_map($replacePipes, $expected);

        $this->assertEquals($expected, $actual);
    }
}
