<?php

// If user not in admin area, abort.
defined('WPINC') || die();

trait Dragonizer_Base_Parser
{
    public static function STRING_CP($bCV = false)
    {
        $sString = '[\'"`]<<' . self::STRING_VALUE() . '>>[\'"`]';

        return self::prepare($sString, $bCV);
    }

    public static function DOUBLE_QUOTE_STRING_VALUE()
    {
        return '(?<=")(?>(?:\\\\.)?[^\\\\"]*+)++';
    }

    public static function SINGLE_QUOTE_STRING_VALUE()
    {
        return "(?<=')(?>(?:\\\\.)?[^\\\\']*+)++";
    }

    public static function BACK_TICK_STRING_VALUE()
    {
        return '(?<=`)(?>(?:\\\\.)?[^\\\\`]*+)++';
    }

    public static function STRING_VALUE()
    {
        return '(?:' . self::DOUBLE_QUOTE_STRING_VALUE() . '|' . self::SINGLE_QUOTE_STRING_VALUE() . '|' . self::BACK_TICK_STRING_VALUE() . ')';
    }

    private static function prepare($sRegex, $bCV)
    {
        $aSearchArray = array('<<<', '>>>', '<<', '>>');

        if ($bCV) {
            return str_replace($aSearchArray, array('(?|', ')', '(', ')'), $sRegex);
        } else {
            return str_replace($aSearchArray, array('(?:', ')', '', ''), $sRegex);
        }
    }

    public static function BLOCK_COMMENT()
    {
        return '/\*(?>\*?[^*]*+)*?\*/';
    }

    public static function LINE_COMMENT()
    {
        return '//[^\r\n]*+';
    }
}

trait Dragonizer_Base_HTML
{
    use Dragonizer_Base_Parser;

    public static function HTML_COMMENT()
    {
        return '<!--(?>-?[^-]*+)*?--!?>';
    }

    public static function HTML_ATTRIBUTE_CP($sAttrName = '', $bCaptureValue = false, $bCaptureDelimiter = false, $sMatchValue = '')
    {
        $sTag = $sAttrName != '' ? $sAttrName : '[^\s/"\'=<>]++';
        $sDel = $bCaptureDelimiter ? '([\'"]?)' : '[\'"]?';

        //If we don't need to match a value then the value of attribute is optional
        if ($sMatchValue == '') {
            $sAttribute = $sTag . '(?:\s*+=\s*+(?>' . $sDel . ')<<' . self::HTML_ATTRIBUTE_VALUE() . '>>[\'"]?)?';
        } else {
            $sAttribute = $sTag . '\s*+=\s*+(?>' . $sDel . ')' . $sMatchValue . '<<' . self::HTML_ATTRIBUTE_VALUE() . '>>[\'"]?';
        }
        return self::prepare($sAttribute, $bCaptureValue);
    }

    public static function HTML_ELEMENT($sElement = '', $bSelfClosing = false)
    {
        $sName = $sElement != '' ? $sElement : '[a-z0-9]++';
        $sTag  = '<' . $sName . '\b[^>]*+>';

        if (!$bSelfClosing) {
            $sTag .= '(?><?[^<]*+)*?</' . $sName . '\s*+>';
        }
        return $sTag;
    }
}

trait Dragonizer_Base_CSS
{
    use Dragonizer_Base_Parser;

    public static function CSS_IDENT()
    {
        return '(?:\\\\.|[a-z0-9_-]++\s++)';
    }

    public static function CSS_URL_CP($bCV = false)
    {
        $sCssUrl = '(?:url\(|(?<=url)\()(?:\s*+[\'"])?<<' . self::CSS_URL_VALUE() . '>>(?:[\'"]\s*+)?\)';

        return self::prepare($sCssUrl, $bCV);
    }

    public static function CSS_URL_VALUE()
    {
        return '(?:' . self::STRING_VALUE() . '|' . self::CSS_URL_VALUE_UNQUOTED() . ')';
    }   
    
    public static function CSS_URL_VALUE_UNQUOTED()
    {
        return '(?<=url\()(?>\s*+(?:\\\\.)?[^\\\\()\s\'"]*+)++';
    }    
}

class Dragonizer_CSS_Parser
{
    use Dragonizer_Base_CSS;

    protected $aExcludes = array();

    protected $oCssSearchObject;

    protected $bBranchReset = true;

    protected $sParseTerm = '\s*+';

    public function __construct()
    {
        $this->aExcludes = array(
            self::BLOCK_COMMENT(),
            self::LINE_COMMENT(),
            self::CSS_RULE_CP(),
            self::CSS_AT_RULES(),
            self::CSS_NESTED_AT_RULES_CP(),
            //Custom exclude
            '\|"(?>[^"{}]*+"?)*?[^"{}]*+"\|',
            self::CSS_INVALID_CSS()
        );
    }

    public static function CSS_RULE_CP($bCaptureValue = false, $sCriteria = '')
    {
        $sCssRule = '<<(?<=^|[{}/\s;|])[^@/\s{}]' . self::parseNoStrings() . '>>\{' . $sCriteria . '<<' . self::parse() . '>>\}';

        return self::prepare($sCssRule, $bCaptureValue);
    }

    protected static function parseNoStrings()
    {
        return '(?>(?:[^{}/]++|/)(?>' . self::BLOCK_COMMENT() . ')?)*?';
    }

    protected static function parse($sInclude = '', $bNoEmpty = false)
    {
        $sRepeat = $bNoEmpty ? '+' : '*';

        return '(?>(?:[^{}"\'/' . $sInclude . ']++|/)(?>' . self::BLOCK_COMMENT() . '|' . self::STRING_CP() . ')?)' . $sRepeat . '?';
    }

    public static function CSS_AT_RULES()
    {
        return '@\w++\b\s++(?:' . self::CSS_IDENT() . ')?' . '(?:' . self::STRING_CP() . '|' . self::CSS_URL_CP() . ')[^;]*+;';
    }

    public static function CSS_NESTED_AT_RULES_CP($aAtRules = array(), $bCV = false, $bEmpty = false)
    {
        $sAtRules = !empty($aAtRules) ? '(?>' . implode('|', $aAtRules) . ')' : '';

        $iN     = $bCV ? 2 : 1;
        $sValue = $bEmpty ? '\s*+' : '(?>' . self::parse('', true) . '|(?-' . $iN . '))*+';

        $sAtRules = '<<@(?:-[^-]++-)??' . $sAtRules . '[^{};]*+>>(\{<<' . $sValue . '>>\})';

        return self::prepare($sAtRules, $bCV);
    }

    public static function CSS_INVALID_CSS()
    {
        return '[^;}@\r\n]*+[;}@\r\n]';
    }

    public static function CSS_AT_IMPORT_CP($bCV = false)
    {
        $sAtImport = '@import\s++<<<' . self::STRING_CP($bCV) . '|' . self::CSS_URL_CP($bCV) . '>>><<[^;]*+>>;';

        return self::prepare($sAtImport, $bCV);
    }

    public static function CSS_AT_CHARSET_CP($sCaptureValue = false)
    {
        return '@charset\s++' . self::STRING_CP($sCaptureValue) . '[^;]*+;';
    }

    public static function CSS_AT_NAMESPACE()
    {
        return '@namespace\s++' . '(?:' . self::CSS_IDENT() . ')?' . '(?:' . self::STRING_CP() . '|' . self::CSS_URL_CP() . ')[^;]*+;';
    }

    public function processMatchesWithCallback($sCss, $oCallback, $sContext = 'global')
    {
        $sRegex = $this->getCssSearchRegex();

        $sProcessedCss = preg_replace_callback('#' . $sRegex . '#six', function ($aMatches) use ($oCallback, $sContext) {

            if (empty(trim($aMatches[0]))) {
                return $aMatches[0];
            }
            if (substr($aMatches[0], 0, 1) == '@') {
                $sContext = $this->getContext($aMatches[0]);

                foreach ($this->oCssSearchObject->getCssNestedRuleNames() as $aAtRule) {
                    if ($aAtRule['name'] == $sContext) {
                        if ($aAtRule['recurse']) {
                            return $aMatches[2] . '{' . $this->processMatchesWithCallback($aMatches[4], $oCallback, $sContext) . '}';
                        } else {
                            return $oCallback->processMatches($aMatches, $sContext);
                        }
                    }
                }
            }
            return $oCallback->processMatches($aMatches, $sContext);
        }, $sCss);

        return $sProcessedCss;
    }

    protected function getCssSearchRegex()
    {
        $sRegex = $this->parseCss($this->getExcludes()) . '\K(?:' . $this->getCriteria() . '|$)';

        return $sRegex;
    }

    protected function parseCSS($aExcludes = array())
    {
        if (!empty($aExcludes)) {
            $aExcludes = '(?>' . implode('|', $aExcludes) . ')?';
        } else {
            $aExcludes = '';
        }
        return '(?>' . $this->sParseTerm . $aExcludes . ')*?' . $this->sParseTerm;
    }

    protected function getExcludes()
    {
        return $this->aExcludes;
    }

    protected function getCriteria()
    {
        $oObj = $this->oCssSearchObject;

        $aCriteria = array();

        //We need to add Nested Rules criteria first to avoid trouble with recursion and branch capture reset
        $aNestedRules = $oObj->getCssNestedRuleNames();

        if (!empty($aNestedRules)) {
            if (count($aNestedRules) == 1 && $aNestedRules[0]['empty-value'] == true) {
                $aCriteria[] = self::CSS_NESTED_AT_RULES_CP(array($aNestedRules[0]['name']), false, true);
            } elseif (count($aNestedRules) == 1 && $aNestedRules[0]['name'] == '*') {
                $aCriteria[] = self::CSS_NESTED_AT_RULES_CP(array());
            } else {
                $aCriteria[] = self::CSS_NESTED_AT_RULES_CP(array_column($aNestedRules, 'name'), true);
            }
        }
        $aAtRules = $oObj->getCssAtRuleCriteria();

        if (!empty($aAtRules)) {
            $aCriteria[] = '(' . implode('|', $aAtRules) . ')';
        }
        $aCssRules = $oObj->getCssRuleCriteria();

        if (!empty($aCssRules)) {
            if (count($aCssRules) == 1 && $aCssRules[0] == '.') {
                $aCriteria[] = self::CSS_RULE_CP(true);
            } elseif (count($aCssRules) == 1 && $aCssRules[0] == '*') {
                //Array of nested rules we don't want to recurse in
                $aNestedRules = array(
                    'font-face',
                    'keyframes',
                    'page',
                    'font-feature-values',
                    'counter-style',
                    'viewport',
                    'property'
                );
                $aCriteria[]  = '(?:(?:' . self::CSS_RULE_CP() . '\s*+|' . self::BLOCK_COMMENT() . '\s*+|' . self::CSS_NESTED_AT_RULES_CP($aNestedRules) . '\s*+)++)';
            } else {
                $sStr = self::getParseStr($aCssRules);

                $sRulesCriteria = '(?=(?>[' . $sStr . ']?[^{}' . $sStr . ']*+)*?(' . implode('|', $aCssRules) . '))';

                $aCriteria[] = self::CSS_RULE_CP(true, $sRulesCriteria);
            }
        }
        $aCssCustomRules = $oObj->getCssCustomRule();

        if (!empty($aCssCustomRules)) {
            $aCriteria[] = '(' . implode('|', $aCssCustomRules) . ')';
        }
        return ($this->bBranchReset ? '(?|' : '(?:') . implode('|', $aCriteria) . ')';
    }

    protected static function getParseStr($aExcludes)
    {
        $aStr = array();

        foreach ($aExcludes as $sExclude) {
            $sSubStr = substr($sExclude, 0, 1);

            if (!in_array($sSubStr, $aStr)) {
                $aStr[] = $sSubStr;
            }
        }
        return implode('', $aStr);
    }

    protected function getContext($sMatch)
    {
        preg_match('#^@(?:-[^-]+-)?([^\s{(]++)#i', $sMatch, $aMatches);

        return !empty($aMatches[1]) ? strtolower($aMatches[1]) : 'global';
    }

    public function replaceMatches($sCss, $sReplace)
    {
        $sProcessedCss = preg_replace('#' . $this->getCssSearchRegex() . '#i', $sReplace, $sCss);

        return $sProcessedCss;
    }

    public function setCssSearchObject(Dragonizer_CssSearchObject $oCssSearchObject)
    {
        $this->oCssSearchObject = $oCssSearchObject;
    }

    public function setExcludes($aExcludes)
    {
        $this->aExcludes = $aExcludes;
    }

    public function setParseTerm($sParseTerm)
    {
        $this->sParseTerm = $sParseTerm;
    }
}

class Dragonizer_HTML_Parser
{
    use Dragonizer_Base_HTML;

    protected $sCriteria = '';

    protected $aExcludes = array();

    protected $aElementObjects = array();

    public static function HTML_HEAD_ELEMENT()
    {
        $aExcludes = array(self::HTML_ELEMENT('script'), self::HTML_COMMENT());

        return '<head\b' . self::parseHtml($aExcludes) . '</head\b\s*+>';
    }

    protected static function parseHtml($aExcludes = array())
    {
        $aExcludes[] = '<';
        $aExcludes   = '(?:' . implode('|', $aExcludes) . ')?';

        return '(?>[^<]*+' . $aExcludes . ')*?[^<]*+';
    }

    protected function setCriteria($bBranchReset = true)
    {
        $aCriteria = array();

        foreach ($this->aElementObjects as $oElement) {
            $sRegex = '<';

            $aNames = implode('|', $oElement->getNamesArray());

            $sRegex .= '(' . $aNames . ')\b\s*+';

            $sRegex .= $this->compileCriteria($oElement);

            $aCaptureAttributes = $oElement->getCaptureAttributesArray();

            if (!empty($aCaptureAttributes)) {
                $mValueCriteria = $oElement->getValueCriteriaRegex();

                if (is_string($mValueCriteria)) {
                    $aValueCriteria = array('.' => $mValueCriteria);
                } else {
                    $aValueCriteria = $mValueCriteria;
                }
                foreach ($aCaptureAttributes as $sCaptureAttribute) {

                    foreach ($aValueCriteria as $sRegexKey => $sValueCriteria) {
                        if ($sValueCriteria != '' && preg_match('#' . $sRegexKey . '#i', $sCaptureAttribute)) {
                            //If criteria is specified for attribute it must match
                            $sRegex .= '(?=' . $this->parseAttributes() .
                                '(' . self::HTML_ATTRIBUTE_CP($sCaptureAttribute, true, true, $sValueCriteria) . '))';
                        } else {
                            //If no criteria specified matching is optional
                            $sRegex .= '(?=(?:' . $this->parseAttributes() .
                                '(' . self::HTML_ATTRIBUTE_CP($sCaptureAttribute, true, true) . '))?)';
                        }
                    }
                }
            }
            if (!empty($aCaptureOneOrBothAttributes = $oElement->getCaptureOneOrBothAttribuesArray())) {
                //Has to be either a string for both attributes or associative array of criteria for both attributes
                $mValueCriteria = $oElement->getValueCriteriaRegex();

                if (is_string($mValueCriteria)) {
                    $aValueCriteria = [
                        $aCaptureOneOrBothAttributes[0] => $mValueCriteria,
                        $aCaptureOneOrBothAttributes[1] => $mValueCriteria
                    ];
                } else {
                    $aValueCriteria = $mValueCriteria;
                }
                $sRegex .= '(?(?=' . $this->parseAttributes() .
                    '(' . self::HTML_ATTRIBUTE_CP($aCaptureOneOrBothAttributes[0], true, true, $aValueCriteria[$aCaptureOneOrBothAttributes[0]]) . '))' .
                    '(?=' . $this->parseAttributes() . '(' . self::HTML_ATTRIBUTE_CP($aCaptureOneOrBothAttributes[1], true, true, $aValueCriteria[$aCaptureOneOrBothAttributes[1]]) . '))?|' .
                    '(?=' . $this->parseAttributes() . '(' . self::HTML_ATTRIBUTE_CP($aCaptureOneOrBothAttributes[1], true, true, $aValueCriteria[$aCaptureOneOrBothAttributes[1]]) . ')))';
            }
            $sRegex .= $this->parseAttributes();
            $sRegex .= '/?>';

            if (!$oElement->bSelfClosing) {
                if ($oElement->bCaptureContent) {
                    $sRegex .= $oElement->getValueCriteriaRegex() . '(' . self::parseHtml() . ')';
                } else {
                    $sRegex .= self::parseHtml();
                }
                $sRegex .= '</(?:' . $aNames . ')\s*+>';
            }
            $aCriteria[] = $sRegex;
        }
        $sCriteria = implode('|', $aCriteria);

        if ($bBranchReset) {
            $this->sCriteria = '(?|' . $sCriteria . ')';
        } else {
            $this->sCriteria = $sCriteria;
        }
    }

    protected function compileCriteria(Dragonizer_ElementObject $oElement)
    {
        $sCriteria = '';

        $aAttrNegCriteria = $oElement->getNegAttrCriteriaArray();

        if (!empty($aAttrNegCriteria)) {
            foreach ($aAttrNegCriteria as $sAttrNegCriteria) {
                $sCriteria .= $this->processNegCriteria($sAttrNegCriteria);
            }
        }
        $aAttrPosCriteria = $oElement->getPosAttrCriteriaArray();

        if (!empty($aAttrPosCriteria)) {
            foreach ($aAttrPosCriteria as $sAttrPosCriteria) {
                $sCriteria .= $this->processPosCriteria($sAttrPosCriteria);
            }
        }
        if ($oElement->bNegateCriteria) {
            $sCriteria = '(?!' . $sCriteria . ')';
        }
        return $sCriteria;
    }

    protected function processNegCriteria($sCriteria)
    {
        return '(?!' . $this->processCriteria($sCriteria) . ')';
    }

    protected function processCriteria($sCriteria)
    {
        return $this->parseAttributes() . '(?:' . str_replace('==', '\s*+=\s*+', $sCriteria) . ')';
    }

    protected function parseAttributes()
    {
        return '(?>' . self::HTML_ATTRIBUTE_CP() . '\s*+)*?';
    }

    protected function processPosCriteria($sCriteria)
    {
        return '(?=' . $this->processCriteria($sCriteria) . ')';
    }

    protected function getExcludes()
    {
        return $this->aExcludes;
    }

    protected function getCriteria()
    {
        return $this->sCriteria;
    }
}

class Dragonizer_CssSearchObject
{
    protected $aCssRuleCriteria = array();
    protected $aCssAtRuleCriteria = array();
    protected $aCssNestedRuleNames = array();
    protected $aCssCustomRule = array();

    public function setCssRuleCriteria($sCriteria)
    {
        $this->aCssRuleCriteria[] = $sCriteria;
    }

    public function getCssRuleCriteria()
    {
        return $this->aCssRuleCriteria;
    }

    public function setCssAtRuleCriteria($sCriteria)
    {
        $this->aCssAtRuleCriteria[] = $sCriteria;
    }

    public function getCssAtRuleCriteria()
    {
        return $this->aCssAtRuleCriteria;
    }

    public function setCssNestedRuleName($sNestedRule, $bRecurse = false, $bEmpty = false)
    {
        $this->aCssNestedRuleNames[] = array(
            'name'        => $sNestedRule,
            'recurse'     => $bRecurse,
            'empty-value' => $bEmpty
        );
    }

    public function getCssNestedRuleNames()
    {
        return $this->aCssNestedRuleNames;
    }

    public function getCssCustomRule()
    {
        return $this->aCssCustomRule;
    }
}

class Dragonizer_ElementObject
{
    public $bNegateCriteria = false;
    protected $aNegAttrCriteria = [];
    protected $aPosAttrCriteria = [];

    public function getNegAttrCriteriaArray()
    {
        return $this->aNegAttrCriteria;
    }

    public function getPosAttrCriteriaArray()
    {
        return $this->aPosAttrCriteria;
    }
}
