<?php
namespace DevOwl\HeadlessContentBlocker;

use DevOwl\FastHtmlTag\FastHtmlTag;
use DevOwl\FastHtmlTag\finder\match\AbstractMatch;
use DevOwl\FastHtmlTag\finder\match\ScriptInlineMatch;
use DevOwl\FastHtmlTag\finder\match\StyleInlineMatch;
use DevOwl\FastHtmlTag\finder\ScriptInlineFinder;
use DevOwl\FastHtmlTag\finder\SelectorSyntaxFinder;
use DevOwl\FastHtmlTag\finder\StyleInlineFinder;
use DevOwl\FastHtmlTag\finder\TagAttributeFinder;
use DevOwl\HeadlessContentBlocker\finder\match\MatchPluginCallbacks;
use DevOwl\HeadlessContentBlocker\finder\match\RerunOnMatchException;
use DevOwl\HeadlessContentBlocker\finder\StyleInlineAttributeFinder;
use DevOwl\HeadlessContentBlocker\matcher\AbstractMatcher;
use DevOwl\HeadlessContentBlocker\matcher\ScriptInlineMatcher;
use DevOwl\HeadlessContentBlocker\matcher\SelectorSyntaxMatcher;
use DevOwl\HeadlessContentBlocker\matcher\StyleInlineAttributeMatcher;
use DevOwl\HeadlessContentBlocker\matcher\StyleInlineMatcher;
use DevOwl\HeadlessContentBlocker\matcher\TagAttributeMatcher;
use DevOwl\HeadlessContentBlocker\plugins\internal\NegatePlugin;
use DevOwl\HeadlessContentBlocker\plugins\SharedAtLeastOne;
use Exception;
use Sabberworm\CSS\CSSList\Document;
use SplObjectStorage;
use ReflectionMethod;

/**
 * Initialize a new headless content blocker.
 */
class HeadlessContentBlocker extends FastHtmlTag {
    /**
     * The tag-attribute map for the usual links like `script[src],link[href],iframe[href]`.
     */
    const TAG_ATTRIBUTE_MAP_LINKABLE = 'linkable';

    private $isSetup = false;

    private $setupCallbacks = [];

    private $afterSetupCallbacks = [];

    private $beforeMatchCallbacks = [];

    private $checkResultCallbacks = [];

    private $blockedMatchCallbacks = [];

    private $notBlockedMatchCallbacks = [];

    private $keepAlwaysAttributes = [];

    private $keepAlwaysAttributesIfClass = [];

    private $keepAlwaysAttributesByCallback = [];

    private $skipInlineScriptVariableAssignments = [];

    private $skipInlineScriptVariableAssignmentsByCallback = [];

    private $iterateBlockablesInStringCallback = [];

    private $inlineStyleDummyUrlPath = null;

    private $inlineStyleShouldBeExtractedByCallback = [];

    private $inlineStyleModifyDocumentsByCallback = [];

    private $inlineStyleBlockRuleByCallback = [];

    private $visualParentCallback = [];

    private $blockableStringExpressionCallback = [];

    private $beforeSetBlockedInResultCallback = [];

    private $modifyBlockablesCallback = [];

    private $replaceAlwaysAttributes = [
        'iframe' => ['sandbox'],
        'script' => ['type'],
        'style' => ['type'],
    ];

    private $visualParentIfClass = [];

    private $allowMultipleBlockerResults = false;

    private $blockablesToHostsCache = null;

    /**
     * List of registered plugins pointing to their object instance.
     *
     * @var AbstractPlugin[][]
     */
    private $plugins = [];

    /**
     * List of blocked elements.
     *
     * @var AbstractBlockable[]
     */
    private $blockables = [];

    /**
     * Pool of all found markups.
     *
     * @var Markup[]
     */
    private $markupPool = [];

    private $markupChain = [];

    private $finderToMatcher;

    private $tagAttributeMap = [
        self::TAG_ATTRIBUTE_MAP_LINKABLE => [
            'tags' => ['script', 'link', 'iframe', 'embed', 'img', 'video', 'source', 'audio'],
            'attr' => ['href', 'data-src', 'src', 'poster'],
        ],
    ];

    /**
     * See `processMatch`.
     *
     * @var RerunOnMatchException[]
     */
    private $rerunExceptions = [];

    /**
     * Selector syntax map.
     *
     * @var string[]
     */
    private $selectorSyntaxMap = [];

    /**
     * This array holds instances of SelectorSyntaxFinder indexed by their associated tag.
     * Each entry is an array containing the first attribute of the finder and the finder instance itself.
     *
     * Why? Get possible selector syntax finders for a match. See also `findPotentialSelectorSyntaxFindersForMatch`.
     *
     * @var array<string, array<string, SelectorSyntaxFinder[]>>
     */
    private $selectorSyntaxFindersTagMatrix = [];

    /**
     * C'tor.
     */
    public function __construct() {
        parent::__construct('HeadlessContentBlocker');
        $this->init();

        $this->addPlugin(NegatePlugin::class);
        $this->addPlugin(SharedAtLeastOne::class);

        $this->finderToMatcher = new SplObjectStorage();
    }

    /**
     * Register a complete plugin to this headless content blocker.
     *
     * @param string $pluginName The class name of the plugin (extended from `AbstractPlugin`)
     */
    public function addPlugin($pluginName) {
        // @codeCoverageIgnoreStart
        if (!class_exists($pluginName) || !is_subclass_of($pluginName, AbstractPlugin::class)) {
            return null;
        }
        // @codeCoverageIgnoreEnd

        /**
         * Plugin.
         *
         * @var AbstractPlugin
         */
        $plugin = new $pluginName($this);
        $plugin->init();

        $className = get_class($plugin);
        $this->plugins[$className] = $this->plugins[$className] ?? [];
        $this->plugins[$className][] = $plugin;

        // Register callbacks
        if ($this->checkIfPluginProvidesCallback($plugin, 'setup')) {
            $this->addSetupCallback([$plugin, 'setup']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'afterSetup')) {
            $this->addAfterSetupCallback([$plugin, 'afterSetup']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'modifyHtmlAfterProcessing')) {
            $this->addCallback([$plugin, 'modifyHtmlAfterProcessing']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'beforeMatch')) {
            $this->addBeforeMatchCallback([$plugin, 'beforeMatch']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'blockedMatch')) {
            $this->addBlockedMatchCallback([$plugin, 'blockedMatch']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'notBlockedMatch')) {
            $this->addNotBlockedMatchCallback([$plugin, 'notBlockedMatch']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'checkResult')) {
            $this->addCheckResultCallback([$plugin, 'checkResult']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'keepAlwaysAttributes')) {
            $this->addKeepAlwaysAttributesCallback([$plugin, 'keepAlwaysAttributes']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'skipInlineScriptVariableAssignment')) {
            $this->addSkipInlineScriptVariableAssignmentsCallback([$plugin, 'skipInlineScriptVariableAssignment']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'iterateBlockablesInString')) {
            $this->addIterateBlockablesInStringCallback([$plugin, 'iterateBlockablesInString']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'visualParent')) {
            $this->addVisualParentCallback([$plugin, 'visualParent']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'inlineStyleShouldBeExtracted')) {
            $this->addInlineStyleShouldBeExtractedCallback([$plugin, 'inlineStyleShouldBeExtracted']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'inlineStyleModifyDocuments')) {
            $this->addInlineStyleModifyDocumentsCallback([$plugin, 'inlineStyleModifyDocuments']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'inlineStyleBlockRule')) {
            $this->addInlineStyleBlockRuleCallback([$plugin, 'inlineStyleBlockRule']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'blockableStringExpression')) {
            $this->addBlockableStringExpressionCallback([$plugin, 'blockableStringExpression']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'beforeSetBlockedInResult')) {
            $this->addBeforeSetBlockedInResultCallback([$plugin, 'beforeSetBlockedInResult']);
        }
        if ($this->checkIfPluginProvidesCallback($plugin, 'modifyBlockables')) {
            $this->addModifyBlockablesCallback([$plugin, 'modifyBlockables']);
        }

        return $plugin;
    }

    /**
     * Check if a plugin provides a callback for a given method. This is useful to only add the callback if the plugin overrides the method
     * and improves performance when iterating the callbacks in the individual `run*Callback` methods.
     *
     * @param object $plugin
     * @param string $methodName
     * @return boolean
     */
    private function checkIfPluginProvidesCallback($plugin, $methodName) {
        try {
            if ($plugin instanceof NegatePlugin) {
                // We should always add all callbacks for at least one plugin to make sure that all callbacks have at least one plugin registered
                return true;
            }

            return (new ReflectionMethod($plugin, $methodName))->getDeclaringClass()->getName() !==
                AbstractPlugin::class;
            // @codeCoverageIgnoreStart
        } catch (Exception $e) {
            // This should never happen, but who knows...
            return true;
        }
        // @codeCoverageIgnoreEnd
    }

    /**
     * Respect additional tags and attributes for a given map. For example, there is
     * the `self::TAG_ATTRIBUTE_MAP_LINKABLE` map which respects known linkable tags
     * like `script[src],link[href],iframe[href]`.
     *
     * @param string[] $tags Additional tags to consider for the passed map
     * @param string[] $attributes Additional tags to consider for the passed map
     * @param string $mapName Default is `self::TAG_ATTRIBUTE_MAP_LINKABLE`
     */
    public function addTagAttributeMap($tags, $attributes = [], $mapName = self::TAG_ATTRIBUTE_MAP_LINKABLE) {
        $this->tagAttributeMap[$mapName] = $this->tagAttributeMap[$mapName] ?? [];

        $map = &$this->tagAttributeMap[$mapName];
        $map['tags'] = array_merge($map['tags'] ?? [], $tags);
        $map['attr'] = array_merge($map['attr'] ?? [], $attributes);
    }

    /**
     * Respect additional selector-syntax as blockable candiates. All mentioned attributes will be checked with the defined blockables.
     * For example, `a[href][class*="my-class"]` will check if `href` got blocked through a blockable, and additionally if the class
     * matches `my-class`.
     *
     * @param string[] $selectorSyntax
     */
    public function addSelectorSyntaxMap($selectorSyntax) {
        $this->selectorSyntaxMap = array_values(array_merge($this->selectorSyntaxMap, $selectorSyntax));
    }

    /**
     * Add a blockable item. In general, this are the URLs and elements you want to block.
     *
     * @param AbstractBlockable[] $blockables
     */
    public function addBlockables($blockables) {
        $this->setBlockables(array_merge($this->blockables, $blockables));
    }

    /**
     * Set blockable items. In general, this are the URLs and elements you want to block.
     *
     * @param AbstractBlockable[] $blockables
     */
    public function setBlockables($blockables) {
        $this->blockables = $this->runModifyBlockablesCallback($blockables);
        $this->blockablesToHostsCache = null;
    }

    /**
     * Add a callable before the content blocker gets setup.
     *
     * @param callable $callback
     */
    public function addSetupCallback($callback) {
        $this->setupCallbacks[] = $callback;
    }

    /**
     * Add a callable after the content blocker got setup.
     *
     * @param callable $callback
     */
    public function addAfterSetupCallback($callback) {
        $this->afterSetupCallbacks[] = $callback;
    }

    /**
     * Add a callable before a match got found (this does not mean that the match is blocked!).
     * Parameters: `AbstractMatcher $matcher, AbstractMatch $match`.
     *
     * @param callable $callback
     */
    public function addBeforeMatchCallback($callback) {
        $this->beforeMatchCallbacks[] = $callback;
    }

    /**
     * Add a callable after a match got found and the matcher decided if it should be blocked or not.
     * Parameters: `BlockedResult $result, AbstractMatcher $matcher, AbstractMatch $match`.
     * This function needs to return a `BlockedResult` instance.
     *
     * @param callable $callback
     */
    public function addCheckResultCallback($callback) {
        $this->checkResultCallbacks[] = $callback;
    }

    /**
     * Add a callable after a blocked match got found so you can alter the match again. Parameters:
     * `BlockedResult $result, AbstractMatcher $matcher, AbstractMatch $match`.
     *
     * @param callable $callback
     */
    public function addBlockedMatchCallback($callback) {
        $this->blockedMatchCallbacks[] = $callback;
    }

    /**
     * Remove a callback added through `addBlockedMatchCallback`.
     *
     * @param callable $callback
     * @codeCoverageIgnore
     */
    public function removeBlockedMatchCallback($callback) {
        $this->blockedMatchCallbacks = array_filter($this->blockedMatchCallbacks, function ($c) use ($callback) {
            return $c !== $callback;
        });
    }

    /**
     * Add a callable after a blocked match got not found, but a match. Parameters:
     * `BlockedResult $result, AbstractMatcher $matcher, AbstractMatch $match`.
     *
     * @param callable $callback
     */
    public function addNotBlockedMatchCallback($callback) {
        $this->notBlockedMatchCallbacks[] = $callback;
    }

    /**
     * In some cases we need to keep the attributes as original instead of prefix it with `consent-original-`.
     * Keep in mind, that no external data should be loaded if the attribute is set!
     *
     * @param string[] $attributes
     */
    public function addKeepAlwaysAttributes($attributes) {
        $this->keepAlwaysAttributes = array_merge($this->keepAlwaysAttributes, $attributes);
    }

    /**
     * See `addKeepAlwaysAttributes`, but consider the keep only if the found match has a given class.
     *
     * @param string[][] $classToAttributesMap
     */
    public function addKeepAlwaysAttributesIfClass($classToAttributesMap) {
        $this->keepAlwaysAttributesIfClass = array_merge_recursive(
            $this->keepAlwaysAttributesIfClass,
            $classToAttributesMap
        );
    }

    /**
     * Add a callable after a match got found and the matcher decided if it should be blocked or not.
     * Parameters: `string[] $keepAttributes, AbstractMatcher $matcher, AbstractMatch $match`.
     * This function needs to return a `string[]` array!
     *
     * @param callable $callback
     */
    public function addKeepAlwaysAttributesCallback($callback) {
        $this->keepAlwaysAttributesByCallback[] = $callback;
    }

    /**
     * Do not block inline scripts starting with `var $variableName =`.
     *
     * @param string[] $names
     */
    public function addSkipInlineScriptVariableAssignments($names) {
        $this->skipInlineScriptVariableAssignments = array_merge($this->skipInlineScriptVariableAssignments, $names);
    }

    /**
     * Add a callable after a match got found and skip a script by custom variable name.
     * Parameters: `string[] $variableNames, ScriptInlineMatcher $matcher, ScriptInlineMatch $match`.
     * This function needs to return a `string[]` array!
     *
     * @param callable $callback
     */
    public function addSkipInlineScriptVariableAssignmentsCallback($callback) {
        $this->skipInlineScriptVariableAssignmentsByCallback[] = $callback;
    }

    /**
     * Add a callable after iterating blockables in a string and allow you to add additional blockables to the result.
     *
     * Note: All required IDs from each matching Blockable will be merged together, but only the ID of the first Blockable found
     * will be assigned as the `consent-id` of the element.
     *
     * Parameters: `AbstractMatcher $matcher, BlockedResult $result, string $string, boolean $useContainsRegularExpression, boolean $multilineRegexp, string[] $useRegularExpressionFromMap, AbstractBlockable[] $useBlockables, boolean $allowMultiple`.
     *
     * @param callable $callback
     */
    public function addIterateBlockablesInStringCallback($callback) {
        $this->iterateBlockablesInStringCallback[] = $callback;
    }

    /**
     * When blocking CSS inline styles, we replace URLs with dummy URLs and we cannot rely on data-uri's
     * as we need to pass parameters, too.
     *
     * Default: `https://assets.devowl.io/packages/devowl-wp/headless-content-blocker/`
     *
     * In the folder there need to exist the following two files: `dummy.css`, `dummy.png`.
     *
     * @param string $urlPath
     * @codeCoverageIgnore
     */
    public function setInlineStyleDummyUrlPath($urlPath) {
        $this->inlineStyleDummyUrlPath = $urlPath;
    }

    /**
     * Decide a visual parent for an individual match.
     * Parameters: `boolean|string|number $useVisualParent, AbstractMatcher $matcher, AbstractMatch $match`.
     * This function needs to return a `boolean|string|number` array!
     *
     * @param callable $callback
     */
    public function addVisualParentCallback($callback) {
        $this->visualParentCallback[] = $callback;
    }

    /**
     * Allows to modify the passed string expression to a blockable.
     * Parameters: `string $expression, Blockable $blockable`.
     * This function needs to return a `string`!
     *
     * @param callable $callback
     */
    public function addBlockableStringExpressionCallback($callback) {
        $this->blockableStringExpressionCallback[] = $callback;
    }

    /**
     * Add a callable before an blockable and expression gets added to a `BlockedResult`.
     *
     * @param callable $callback
     */
    public function addBeforeSetBlockedInResultCallback($callback) {
        $this->beforeSetBlockedInResultCallback[] = $callback;
    }

    /**
     * Add a callable to modify the blockables array before it gets registered.
     *
     * @param callable $callback
     */
    public function addModifyBlockablesCallback($callback) {
        $this->modifyBlockablesCallback[] = $callback;
    }

    /**
     * A set of HTML tags => attribute names which should always prefix with `consent-original-`.
     *
     * @param string[][] $tagToAttributesMap
     * @codeCoverageIgnore
     */
    public function addReplaceAlwaysAttributes($tagToAttributesMap) {
        $this->replaceAlwaysAttributes = array_merge_recursive($this->replaceAlwaysAttributes, $tagToAttributesMap);
    }

    /**
     * Add a callable after a blocked inline style got found and skip creation of a second document with blocked URLs.
     * Parameters: `boolean $extract, StyleInlineMatcher $matcher, StyleInlineMatch $match`.
     * This function needs to return a `boolean` array!
     *
     * @param callable $callback
     */
    public function addInlineStyleShouldBeExtractedCallback($callback) {
        $this->inlineStyleShouldBeExtractedByCallback[] = $callback;
    }

    /**
     * Add a callable after a blocked inline style got found, parsed and you can modify the `Document` instance.
     * Parameters: `boolean $extract, StyleInlineMatcher $matcher, StyleInlineMatch $match`.
     *
     * @param callable $callback
     */
    public function addInlineStyleModifyDocumentsCallback($callback) {
        $this->inlineStyleModifyDocumentsByCallback[] = $callback;
    }

    /**
     * Add a callable after a blocked inline CSS rule got found, and we need to decide to block it or not.
     * Parameters: `BlockedResult $extract, string $url, StyleInlineMatcher $matcher, StyleInlineMatch $match`.
     *
     * @param callable $callback
     */
    public function addInlineStyleBlockRuleCallback($callback) {
        $this->inlineStyleBlockRuleByCallback[] = $callback;
    }

    /**
     * See `Constants::HTML_ATTRIBUTE_VISUAL_PARENT` for more details about this mechanism.
     *
     * @param string[][] $classToVisualParent
     */
    public function addVisualParentIfClass($classToVisualParent) {
        $this->visualParentIfClass = array_merge_recursive($this->visualParentIfClass, $classToVisualParent);
    }

    /**
     * Setup the content blocker mechanism. You need to use this before you `modifyAny/modifyHtml`
     * your content!
     */
    public function setup() {
        if ($this->isSetup) {
            return;
        }

        $this->isSetup = true;

        $this->runSetupCallback();

        // Block by inline script
        $inlineScriptMatcher = new ScriptInlineMatcher($this);
        $inlineScriptFinder = new ScriptInlineFinder();
        $inlineScriptFinder->addCallback(function ($match) use ($inlineScriptMatcher) {
            $this->processMatch($inlineScriptMatcher, $match);
        });
        $this->addFinder($inlineScriptFinder);

        // Block by tag-attribute map
        $tagAttributeMatcher = new TagAttributeMatcher($this);
        foreach ($this->tagAttributeMap as $map) {
            foreach ($map['attr'] as $attr) {
                $tagAttributeFinder = new TagAttributeFinder($map['tags'], [$attr]);
                $tagAttributeFinder->addCallback(function ($match) use ($tagAttributeMatcher) {
                    $this->processMatch($tagAttributeMatcher, $match);
                });
                $this->addFinder($tagAttributeFinder);
            }
        }

        // Block by inline style
        $inlineStyleMatcher = new StyleInlineMatcher($this);
        $inlineStyleFinder = new StyleInlineFinder();
        $inlineStyleFinder->addCallback(function ($match) use ($inlineStyleMatcher) {
            $this->processMatch($inlineStyleMatcher, $match);
        });
        $this->addFinder($inlineStyleFinder);

        // Block by SelectorSyntax (Custom Element Blocker)
        foreach ($this->getBlockables() as $blockable) {
            foreach ($blockable->getSelectorSyntaxFinder() as $selectorSyntaxFinder) {
                $selectorSyntaxMatcher = new SelectorSyntaxMatcher($this, $blockable);
                $selectorSyntaxFinder->addCallback(function ($match) use ($selectorSyntaxMatcher) {
                    $this->processMatch($selectorSyntaxMatcher, $match);
                });
                $this->addFinder($selectorSyntaxFinder);
                $this->finderToMatcher[$selectorSyntaxFinder] = $selectorSyntaxMatcher;
            }
        }

        // Block by selector-syntax-map
        foreach ($this->selectorSyntaxMap as $selectorSyntax) {
            $selectorSyntaxFinder = SelectorSyntaxFinder::fromExpression($selectorSyntax);
            if ($selectorSyntaxFinder !== false) {
                $selectorSyntaxFinder->setFastHtmlTag($this);

                // Find matches which are not yet covered by the selector-syntax-map in previous calls
                $selectorSyntaxMatcher = new SelectorSyntaxMatcher($this, null, false);
                $selectorSyntaxFinder->addCallback(function ($match) use ($selectorSyntaxMatcher) {
                    $this->processMatch($selectorSyntaxMatcher, $match);
                });
                $this->addFinder($selectorSyntaxFinder);
                $this->finderToMatcher[$selectorSyntaxFinder] = $selectorSyntaxMatcher;
            }
        }

        // Block by inline style within HTML attribute `style=""`
        $styleInlineAttributeMatcher = new StyleInlineAttributeMatcher($this);
        $styleInlineAttributeFinder = new StyleInlineAttributeFinder();
        $styleInlineAttributeFinder->addCallback(function ($match) use ($styleInlineAttributeMatcher) {
            $this->processMatch($styleInlineAttributeMatcher, $match);
        });
        $this->addFinder($styleInlineAttributeFinder);

        $this->runAfterSetupCallback();
    }

    /**
     * When adding a finder, save the instances of `SelectorSyntaxFinder`.
     *
     * @param AbstractFinder $finder
     */
    public function addFinder($finder) {
        parent::addFinder($finder);
        if ($finder instanceof SelectorSyntaxFinder) {
            $tag = $finder->getTag();
            $firstAttribute = $finder->getAttributes()[0]->getAttribute();

            $this->selectorSyntaxFindersTagMatrix[$tag] = $this->selectorSyntaxFindersTagMatrix[$tag] ?? [];
            $this->selectorSyntaxFindersTagMatrix[$tag][] = [$firstAttribute, $finder];
        }
    }

    /**
     * A match got found from one of our finders. Run plugins and hooks.
     *
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     */
    public function processMatch($matcher, $match) {
        $originalMatch = $match->getOriginalMatch();

        $rerunExceptionDispatcher = [];
        foreach ($this->rerunExceptions as $idx => $exception) {
            if ($match->getInvisibleAttribute(RerunOnMatchException::ID_ATTRIBUTE_NAME) === $exception->getId()) {
                $afterProcessing = $exception->getAfterProcessing();
                $rerunExceptionDispatcher[] = function () use ($afterProcessing, $match, $matcher) {
                    try {
                        if (is_callable($afterProcessing)) {
                            $afterProcessing($match, $matcher);
                        }
                    } catch (RerunOnMatchException $e) {
                        $this->registerRerun();
                        $this->rerunExceptions[] = $e;
                    }
                };
                unset($this->rerunExceptions[$idx]);
            }
        }
        $this->rerunExceptions = array_values($this->rerunExceptions);

        $this->runBeforeMatchCallback($matcher, $match);

        $result = null;
        try {
            $result = $matcher->match($match);
        } catch (RerunOnMatchException $e) {
            $this->registerRerun();
            $this->rerunExceptions[] = $e;

            // When writing tests for this function I did no longer found a reproduce case for this but I keep this for backwards-compatibility
            // @codeCoverageIgnoreStart
            foreach ($rerunExceptionDispatcher as $c) {
                $c();
            }
            // @codeCoverageIgnoreEnd

            $this->persistMarkupChain($originalMatch, $match);
            return;
        }

        if ($result->isBlocked()) {
            $this->runBlockedMatchCallback($result, $matcher, $match);
        } else {
            $this->runNotBlockedMatchCallback($result, $matcher, $match);
        }

        // Apply data URL transformations to blocked content
        if ($match->hasChanged()) {
            foreach ($match->getAttributes() as $key => $val) {
                $originalAttributeKey = AttributesHelper::revertTransformAttribute($key);
                if ($originalAttributeKey !== false) {
                    $mimeType = $match->isAttributeDataUrl($originalAttributeKey);
                    if ($mimeType !== false) {
                        // When writing tests for this function I did no longer found a reproduce case for this but I keep this for backwards-compatibility
                        // @codeCoverageIgnoreStart
                        $match->setAttribute($key, $val, $mimeType);
                        // @codeCoverageIgnoreEnd
                    }
                }
            }
        }

        foreach ($rerunExceptionDispatcher as $c) {
            $c();
        }

        $this->persistMarkupChain($originalMatch, $match);
    }

    /**
     * Persist the markup chain so we can reconstruct the original match in the scanner.
     *
     * @param string $originalMatch
     * @param AbstractMatch $match
     */
    protected function persistMarkupChain($originalMatch, $match) {
        if (!empty($originalMatch) && $match->hasChanged()) {
            // Temporarily disable before and after tag as this is not needed for the chain
            $beforeTag = $match->getBeforeTag();
            $afterTag = $match->getAfterTag();
            $match->setBeforeTag('');
            $match->setAfterTag('');

            $to = md5($match->render());
            $from = md5($originalMatch);
            if ($to !== $from) {
                $this->markupChain[$to] = $from;
            }

            $match->setBeforeTag($beforeTag);
            $match->setAfterTag($afterTag);
        }
    }

    /**
     * Run registered setup callbacks.
     */
    protected function runSetupCallback() {
        foreach ($this->setupCallbacks as $callback) {
            $callback();
        }
    }

    /**
     * Run registered after-setup callbacks.
     */
    protected function runAfterSetupCallback() {
        foreach ($this->afterSetupCallbacks as $callback) {
            $callback();
        }
    }

    /**
     * Run registered before-match callbacks.
     *
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     */
    protected function runBeforeMatchCallback($matcher, $match) {
        foreach ($this->beforeMatchCallbacks as $callback) {
            $callback($matcher, $match);
        }
    }

    /**
     * Run registered blocked-match callbacks.
     *
     * @param BlockedResult $result
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     */
    protected function runBlockedMatchCallback($result, $matcher, $match) {
        foreach ($this->blockedMatchCallbacks as $callback) {
            $callback($result, $matcher, $match);
        }

        // Delegate `MatchPluginCallbacks`
        MatchPluginCallbacks::getFromMatch($match)->runBlockedMatchCallback($result, $matcher);
        $result->runBlockedMatchCallback($matcher, $match);
    }

    /**
     * Run registered not-blocked-match callbacks.
     *
     * @param BlockedResult $result
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     */
    protected function runNotBlockedMatchCallback($result, $matcher, $match) {
        foreach ($this->notBlockedMatchCallbacks as $callback) {
            $callback($result, $matcher, $match);
        }
    }

    /**
     * Run registered check-result callbacks.
     *
     * @param BlockedResult $result
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     * @return BlockedResult
     */
    public function runCheckResultCallback($result, $matcher, $match) {
        foreach ($this->checkResultCallbacks as $callback) {
            $result = $callback($result, $matcher, $match);
        }
        return $result;
    }

    /**
     * Run registered keep-always-attributes callbacks.
     *
     * @param string[] $keepAttributes
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     * @return string[]
     */
    public function runKeepAlwaysAttributesCallback($keepAttributes, $matcher, $match) {
        foreach ($this->keepAlwaysAttributesByCallback as $callback) {
            $keepAttributes = $callback($keepAttributes, $matcher, $match);
        }
        return $keepAttributes;
    }

    /**
     * Run registered skip-inline-names callbacks.
     *
     * @param string[] $names
     * @param ScriptInlineMatcher $matcher
     * @param ScriptInlineMatch $match
     * @return string[]
     */
    public function runSkipInlineScriptVariableAssignmentsCallback($names, $matcher, $match) {
        foreach ($this->skipInlineScriptVariableAssignmentsByCallback as $callback) {
            $names = $callback($names, $matcher, $match);
        }
        return $names;
    }

    /**
     * Run registered callbacks to not extract blocked CSS rules to a second document.
     *
     * @param boolean $extract
     * @param StyleInlineMatcher $matcher
     * @param StyleInlineMatch $match
     * @return boolean
     */
    public function runInlineStyleShouldBeExtractedByCallback($extract, $matcher, $match) {
        foreach ($this->inlineStyleShouldBeExtractedByCallback as $callback) {
            $extract = $callback($extract, $matcher, $match);
        }
        return $extract;
    }

    /**
     * Run registered callbacks to iterate blockables in a string.
     *
     * @param AbstractMatcher $matcher
     * @param BlockedResult $result
     * @param string $string
     * @param boolean $useContainsRegularExpression
     * @param boolean $multilineRegexp
     * @param string[] $useRegularExpressionFromMap
     * @param AbstractBlockable[] $blockables
     * @param boolean $allowMultiple
     */
    public function runIterateBlockablesInStringCallback(
        $matcher,
        $result,
        $string,
        $useContainsRegularExpression,
        $multilineRegexp,
        $useRegularExpressionFromMap,
        $blockables,
        $allowMultiple
    ) {
        foreach ($this->iterateBlockablesInStringCallback as $callback) {
            $callback(
                $matcher,
                $result,
                $string,
                $useContainsRegularExpression,
                $multilineRegexp,
                $useRegularExpressionFromMap,
                $blockables,
                $allowMultiple
            );
        }
    }

    /**
     * Run registered callbacks to not extract blocked CSS rules to a second document.
     *
     * @param Document $document
     * @param Document $extractedDocument
     * @param StyleInlineMatcher $matcher
     * @param StyleInlineMatch $match
     */
    public function runInlineStyleModifyDocumentsCallback($document, $extractedDocument, $matcher, $match) {
        foreach ($this->inlineStyleModifyDocumentsByCallback as $callback) {
            $callback($document, $extractedDocument, $matcher, $match);
        }
    }

    /**
     * Run registered check-result callbacks.
     *
     * @param BlockedResult $result
     * @param string $url
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     * @return BlockedResult
     */
    public function runInlineStyleBlockRuleCallback($result, $url, $matcher, $match) {
        foreach ($this->inlineStyleBlockRuleByCallback as $callback) {
            $result = $callback($result, $url, $matcher, $match);
        }
        return $result;
    }

    /**
     * Run registered check-result callbacks.
     *
     * @param boolean|string|number $visualParent
     * @param AbstractMatcher $matcher
     * @param AbstractMatch $match
     */
    public function runVisualParentCallback($visualParent, $matcher, $match) {
        foreach ($this->visualParentCallback as $callback) {
            $visualParent = $callback($visualParent, $matcher, $match);
        }
        return $visualParent;
    }

    /**
     * Run registered blockable string expression callbacks.
     *
     * @param string $expression
     * @param AbstractBlockable $blockable
     */
    public function runBlockableStringExpressionCallback($expression, $blockable) {
        foreach ($this->blockableStringExpressionCallback as $callback) {
            $expression = $callback($expression, $blockable);
        }
        return $expression;
    }

    /**
     * Run registered callbacks before a blockable and expression gets added to a `BlockedResult`.
     *
     * @param BlockedResult $result
     * @param AbstractBlockable $blockable
     * @param string $expression
     * @param AbstractMatcher $matcher
     * @return boolean
     */
    public function runBeforeSetBlockedInResultCallback($result, $blockable, $expression, $matcher) {
        $returnResult = true;
        foreach ($this->beforeSetBlockedInResultCallback as $callback) {
            $returnResult = $callback($result, $blockable, $expression, $matcher);

            // @codeCoverageIgnoreStart
            if ($returnResult === false) {
                break;
            }
            // @codeCoverageIgnoreEnd
        }
        return $returnResult;
    }

    /**
     * Run registered callbacks to modify the blockables array before it gets registered.
     *
     * @param AbstractBlockable[] $blockables
     * @return AbstractBlockable[]
     */
    public function runModifyBlockablesCallback($blockables) {
        foreach ($this->modifyBlockablesCallback as $callback) {
            $blockables = $callback($blockables);
        }
        return $blockables;
    }

    /**
     * Add a callback which should throw an `Exception` when the content blocker is not setup.
     */
    protected function init() {
        $this->addCallback(function ($html) {
            // @codeCoverageIgnoreStart
            if (!$this->isSetup) {
                throw new Exception('Please setup() your headless content blocker before modifying your content!');
            }
            // @codeCoverageIgnoreEnd

            return $html;
        });
    }

    /**
     * Create an expression => regular expression cache for all available URLs in available blockables.
     *
     * @param boolean $contains
     * @param AbstractBlockable[] $useBlockables
     */
    public function blockablesToHosts($contains = true, $useBlockables = null) {
        $prepareRows = function ($regex) use ($contains) {
            if (!is_string($regex)) {
                return $regex;
            }
            return $contains ? '/' . substr($regex, 2, strlen($regex) - 4) . '/' : $regex;
        };

        if ($this->blockablesToHostsCache !== null && $useBlockables === null) {
            return array_map($prepareRows, $this->blockablesToHostsCache);
        }

        $result = [];

        $forEachBlockables = $useBlockables === null ? $this->getBlockables() : $useBlockables;
        foreach ($forEachBlockables as $blockable) {
            // Iterate all wildcard URLs
            foreach ($blockable->getRegularExpressions() as $expression => $regex) {
                if (!isset($result[$expression]) && !empty($expression)) {
                    // First, mark as non-host / non-URL
                    $result[$expression] = false;

                    $useExpression = trim($expression, '*');
                    if (
                        !Utils::startsWith($useExpression, 'http://') &&
                        !Utils::startsWith($useExpression, 'https://')
                    ) {
                        $useExpression = 'https://' . $useExpression;
                    }

                    if (filter_var($useExpression, FILTER_VALIDATE_URL)) {
                        $useExpression = parse_url($useExpression);
                        $useExpression = $useExpression['host'];

                        if (count(explode('.', $useExpression)) > 1) {
                            // https://regex101.com/r/oDeUCV/2
                            $useExpression = sprintf(
                                '/^(https:|http:)?\/\/(www\.)?%s$/',
                                preg_quote($useExpression, '/')
                            );
                            $result[$expression] = $useExpression;
                        }
                    }
                }
            }
        }

        if ($useBlockables === null) {
            $this->blockablesToHostsCache = $result;
        }
        return array_map($prepareRows, $result);
    }

    /**
     * Find potential selector syntax finders for a given match. You need to use `matchesAttributes` on the match
     * to check if the match is covered by the returned finders.
     *
     * @param string $tag
     * @param string[] $attributeNames
     * @return SelectorSyntaxFinder[]
     */
    public function findPotentialSelectorSyntaxFindersForMatch($tag, $attributeNames) {
        $result = [];
        $tagFinders = $this->selectorSyntaxFindersTagMatrix[$tag] ?? [];
        foreach ($tagFinders as $entry) {
            $attributeName = $entry[0];
            $finder = $entry[1];
            if (in_array($attributeName, $attributeNames, true)) {
                $result[] = $finder;
            }
        }

        return $result;
    }

    /**
     * Get blockable rules starting with a given string. This does only work for non-Selector-Syntax expressions.
     *
     * Example: `avf_exclude_assets:avia_google_maps_api_script` -> search by `avf_exclude_assets:` and it will return
     * an array with `[['expression' => 'avia_google_maps_api_script', 'blockable' => AbstractBlockable instance]]`.
     *
     * @param string $prefix
     * @param boolean $onlyExpressions
     * @param AbstractBlockable[] $blockables
     * @return array[]
     */
    public function getBlockableRulesStartingWith($prefix, $onlyExpressions = false, $blockables = null) {
        $result = [];
        $blockables = $blockables ?? $this->getBlockables();
        foreach ($blockables as $blockable) {
            foreach ($blockable->getOriginalExpressions() as $expression) {
                if (Utils::startsWith($expression, $prefix)) {
                    $result[] = [
                        'expression' => substr($expression, strlen($prefix)),
                        'blockable' => $blockable,
                    ];
                }
            }
        }

        return $onlyExpressions ? array_column($result, 'expression') : $result;
    }

    /**
     * Get blockable by blocker ID.
     *
     * @param int $id
     */
    public function getBlockableById($id) {
        foreach ($this->getBlockables() as $blockable) {
            if ($blockable->getBlockerId() === $id) {
                return $blockable;
            }
        }

        // @codeCoverageIgnoreStart
        return null;
        // @codeCoverageIgnoreEnd
    }

    /**
     * If you pass `true`, the generated `BlockedResult` will contain multiple results and will not
     * break after the first found blockable to block.
     *
     * @param boolean $status
     */
    public function setAllowMultipleBlockerResults($status) {
        $this->allowMultipleBlockerResults = $status;
    }

    /**
     * Getter.
     */
    public function isAllowMultipleBlockerResults() {
        return $this->allowMultipleBlockerResults;
    }

    /**
     * Getter.
     */
    public function getTagAttributeMap() {
        return $this->tagAttributeMap;
    }

    /**
     * Getter.
     *
     * @codeCoverageIgnore
     */
    public function getSelectorSyntaxMap() {
        return $this->selectorSyntaxMap;
    }

    /**
     * Getter.
     */
    public function getBlockables() {
        return $this->blockables;
    }

    /**
     * Getter.
     */
    public function getKeepAlwaysAttributes() {
        return $this->keepAlwaysAttributes;
    }

    /**
     * Getter.
     */
    public function getKeepAlwaysAttributesIfClass() {
        return $this->keepAlwaysAttributesIfClass;
    }

    /**
     * Getter.
     */
    public function getSkipInlineScriptVariableAssignments() {
        return $this->skipInlineScriptVariableAssignments;
    }

    /**
     * Getter.
     */
    public function getInlineStyleDummyUrlPath() {
        return $this->inlineStyleDummyUrlPath;
    }

    /**
     * Getter.
     */
    public function getReplaceAlwaysAttributes() {
        return $this->replaceAlwaysAttributes;
    }

    /**
     * Getter.
     */
    public function getVisualParentIfClass() {
        return $this->visualParentIfClass;
    }

    /**
     * Getter.
     *
     * @codeCoverageIgnore
     */
    public function getPlugins() {
        return $this->plugins;
    }

    /**
     * Getter.
     */
    public function getFinderToMatcher() {
        return $this->finderToMatcher;
    }

    /**
     * Getter.
     *
     * @param string $className
     * @codeCoverageIgnore
     */
    public function getPluginsByClassName($className) {
        return $this->plugins[$className] ?? null;
    }

    /**
     * Getter.
     *
     * This also allows you to clear the markup pool by using `= []` to the retrieved reference.
     */
    public function &getMarkupPool() {
        return $this->markupPool;
    }

    /**
     * Find the original markup from a given markup without any transformations.
     *
     * @param Markup $markup
     */
    public function findOriginalMarkup($markup) {
        // @codeCoverageIgnoreStart
        if ($markup === null) {
            return null;
        }
        // @codeCoverageIgnoreEnd

        $id = $markup->getId();
        while (isset($this->markupChain[$id])) {
            $id = $this->markupChain[$id];
        }
        return $this->markupPool[$id] ?? $markup;
    }
}
