<?php
namespace DevOwl\HeadlessContentBlocker\matcher;

use DevOwl\FastHtmlTag\finder\match\AbstractMatch;
use DevOwl\FastHtmlTag\finder\match\SelectorSyntaxMatch;
use DevOwl\HeadlessContentBlocker\AbstractBlockable;
use DevOwl\HeadlessContentBlocker\AttributesHelper;
use DevOwl\HeadlessContentBlocker\BlockedResult;
use DevOwl\HeadlessContentBlocker\Constants;
use DevOwl\HeadlessContentBlocker\finder\match\MatchPluginCallbacks;
use DevOwl\HeadlessContentBlocker\HeadlessContentBlocker;
use DevOwl\HeadlessContentBlocker\Markup;

/**
 * A matcher describes a class which gets a match from the `FastHtmlTag` and will
 * modify the tag as needed.
 */
abstract class AbstractMatcher {
    private $headlessContentBlocker;

    private $currentlyInIterateBlockablesInString = false;

    /**
     * C'tor.
     *
     * @param HeadlessContentBlocker $headlessContentBlocker
     */
    public function __construct($headlessContentBlocker) {
        $this->headlessContentBlocker = $headlessContentBlocker;
    }

    /**
     * Modify the result of a tag with attributes. Please use this in conjunction
     * with `checkMatch`.
     *
     * @param AbstractMatch $match
     * @return BlockedResult
     */
    abstract public function match($match);

    /**
     * Check if a given match is blocked and return a `BlockedResult` instance.
     *
     * @param AbstractMatch $match
     * @return BlockedResult
     */
    abstract public function createResult($match);

    /**
     * Create a basic `BlockedResult` from an `AbstractMatch`.
     *
     * @param AbstractMatch $match
     */
    public function createPlainResultFromMatch($match) {
        return new BlockedResult(
            $match->getTag(),
            $match->getAttributes(),
            Markup::persist($match->getOriginalMatch(), $this->getHeadlessContentBlocker())
        );
    }

    /**
     * Disable blocked result if it has the skipped-attribute.
     *
     * @param BlockedResult $result
     * @param AbstractMatch $match
     */
    public function probablyDisableDueToSkipped($result, $match) {
        if ($result->isBlocked() && AttributesHelper::isSkipped($match->getAttributes())) {
            $result->disableBlocking();
        }
    }

    /**
     * Iterate our blockables in a given string and save results to the `BlockedResult`.
     *
     * @param BlockedResult $result
     * @param string|string[] $string
     * @param boolean $useContainsRegularExpression
     * @param boolean $multilineRegexp
     * @param string[] $useRegularExpressionFromMap
     * @param AbstractBlockable[] $useBlockables
     * @param boolean $forceAllowMultiple
     */
    public function iterateBlockablesInString(
        $result,
        $string,
        $useContainsRegularExpression = false,
        $multilineRegexp = false,
        $useRegularExpressionFromMap = null,
        $useBlockables = null,
        $forceAllowMultiple = null
    ) {
        $cb = $this->getHeadlessContentBlocker();
        $allowMultiple = $forceAllowMultiple === null ? $cb->isAllowMultipleBlockerResults() : $forceAllowMultiple;
        $string = is_array($string) ? $string : $this->prepareChunksFromString($string);
        $blockables = $useBlockables === null ? $cb->getBlockables() : $useBlockables;

        foreach ($blockables as $blockable) {
            $regularExpressions = $useContainsRegularExpression
                ? $blockable->getContainsRegularExpressions()
                : $blockable->getRegularExpressions();
            foreach ($regularExpressions as $expression => $regex) {
                $useRegex =
                    $useRegularExpressionFromMap === null ? $regex : $useRegularExpressionFromMap[$expression] ?? false;

                if (!$useRegex) {
                    continue;
                }

                foreach ($string as $chunkString) {
                    // Before doing an expensive regular expression match, check if the string generally exists in our chunk
                    // Do not create the cache for custom maps as they can differ from expression -> regular expression mapping (e.g. `blockablesToHosts`)
                    // This could lead to wrong `strpos` assertions in the inner `foreach`
                    if (
                        $useRegularExpressionFromMap === null &&
                        !$blockable->matchesExpressionLoose($expression, $chunkString)
                    ) {
                        continue;
                    }

                    if (preg_match($useRegex . ($multilineRegexp ? 'm' : ''), $chunkString)) {
                        $pluginResult = true;
                        if (!$this->currentlyInIterateBlockablesInString) {
                            $this->currentlyInIterateBlockablesInString = true;
                            $pluginResult = $this->getHeadlessContentBlocker()->runBeforeSetBlockedInResultCallback(
                                $result,
                                $blockable,
                                $expression,
                                $this
                            );
                            $this->currentlyInIterateBlockablesInString = false;
                        }

                        // @codeCoverageIgnoreStart
                        if ($pluginResult === false) {
                            continue;
                        }
                        // @codeCoverageIgnoreEnd

                        // This link is definitely blocked by configuration
                        if (!$result->isBlocked()) {
                            $result->setBlocked([$blockable]);
                            $result->setBlockedExpressions([$expression]);
                        }

                        if ($allowMultiple) {
                            $result->addBlocked($blockable);
                            $result->addBlockedExpression($expression);
                            continue 2;
                        } else {
                            break 3;
                        }
                    }
                }
            }
        }

        $cb->runIterateBlockablesInStringCallback(
            $this,
            $result,
            $string,
            $useContainsRegularExpression,
            $multilineRegexp,
            $useRegularExpressionFromMap,
            $blockables,
            $allowMultiple
        );
    }

    /**
     * Prepare chunks cause `pcre.jit` can lead to `PREG_JIT_STACKLIMIT_ERROR` errors
     * In a customer scenario, it lead to an error with a string length of `8191`.
     *
     * As we are using `preg_match` we need to ensure, that the blockable expression can find
     * strings between two chunks (yeah, this can happen). So, add a small part of the previous
     * and next chunk (`$copySiblingChunkStringLength`).
     *
     * @param string $string
     * @param int $maxChunkLength
     * @param int $copySiblingChunkStringLength
     */
    protected function prepareChunksFromString($string, $maxChunkLength = 5000, $copySiblingChunkStringLength = 100) {
        $string = str_split($string, $maxChunkLength);
        $chunks = [];

        foreach ($string as $chunkIdx => $chunkString) {
            // Previous sibling
            if ($chunkIdx > 0) {
                $siblingChunkString = $string[$chunkIdx - 1] ?? '';
                $siblingChunkString =
                    strlen($siblingChunkString) >= $copySiblingChunkStringLength
                        ? substr($siblingChunkString, $copySiblingChunkStringLength * -1)
                        : // @codeCoverageIgnoreStart
                        $siblingChunkString;
                // @codeCoverageIgnoreEnd
                $chunkString = $siblingChunkString . $chunkString;
            }

            // Next sibling
            $siblingChunkString = substr($string[$chunkIdx + 1] ?? '', 0, $copySiblingChunkStringLength);
            $chunks[] = $chunkString . $siblingChunkString;
        }

        return $chunks;
    }

    /**
     * Apply common attributes for our blocked element:
     *
     * - Visual parent
     * - Replaced link attribute (optional)
     * - Consent attributes depending on blocked item (`consent-required`, ...)
     * - Replace always attributes
     *
     * @param BlockedResult $result
     * @param AbstractMatch $match
     * @param string $linkAttribute
     * @param string $link
     */
    protected function applyCommonAttributes($result, $match, $linkAttribute = null, $link = null) {
        $this->applyVisualParent($match);

        $newLinkAttribute = null;
        if ($linkAttribute !== null) {
            $newLinkAttribute = $this->applyNewLinkElement($match, $linkAttribute, $link);
        }

        $scriptType = $match->getAttribute(
            Constants::HTML_ATTRIBUTE_TYPE_NAME,
            $match->isTag('script') ? Constants::HTML_ATTRIBUTE_TYPE_JS : Constants::HTML_ATTRIBUTE_TYPE_CSS
        );

        $this->applyConsentAttributes($result, $match);
        $this->applyReplaceAlwaysAttributes($match);

        if (
            in_array(strtolower($match->getTag()), Constants::HTML_ATTRIBUTE_TYPE_FOR, true) &&
            $scriptType !== Constants::HTML_ATTRIBUTE_TYPE_VALUE
        ) {
            $newTypeAttribute = AttributesHelper::transformAttribute(Constants::HTML_ATTRIBUTE_TYPE_NAME);
            $match->setAttribute($newTypeAttribute, $scriptType);
            $match->setAttribute(Constants::HTML_ATTRIBUTE_TYPE_NAME, Constants::HTML_ATTRIBUTE_TYPE_VALUE);
        }

        $result->setData('newLinkAttribute', $newLinkAttribute);
    }

    /**
     * Replace all known attributes which should be always replaced.
     *
     * @param AbstractMatch $match
     */
    protected function applyReplaceAlwaysAttributes($match) {
        $tag = strtolower($match->getTag());
        $replaceAlwaysAttributes = $this->getHeadlessContentBlocker()->getReplaceAlwaysAttributes();
        if (isset($replaceAlwaysAttributes[$tag])) {
            foreach ($replaceAlwaysAttributes[$tag] as $attr) {
                $newAttrName = AttributesHelper::transformAttribute($attr);
                if ($match->hasAttribute($attr) && !$match->hasAttribute($newAttrName)) {
                    $match->setAttribute($newAttrName, $match->getAttribute($attr));
                    $match->setAttribute($attr, null);
                }
            }
        }
    }

    /**
     * Create HTML attributes for the content blocker.
     *
     * @param BlockedResult $result
     * @param AbstractMatch $match
     */
    public function applyConsentAttributes($result, $match) {
        foreach ($result->getBlocked() as $blocker) {
            if ($blocker->hasBlockerId()) {
                $requiredIds = $blocker->getRequiredIds();
                $alreadyRequiredIds = [];
                if ($match->hasAttribute(Constants::HTML_ATTRIBUTE_COOKIE_IDS)) {
                    $alreadyRequiredIds = explode(',', $match->getAttribute(Constants::HTML_ATTRIBUTE_COOKIE_IDS));
                }

                $match->setAttribute(
                    Constants::HTML_ATTRIBUTE_COOKIE_IDS,
                    join(',', array_unique(array_merge($requiredIds, $alreadyRequiredIds)))
                );

                if (!$match->hasAttribute(Constants::HTML_ATTRIBUTE_BY)) {
                    $match->setAttribute(Constants::HTML_ATTRIBUTE_BY, $blocker->getCriteria());
                }

                if (!$match->hasAttribute(Constants::HTML_ATTRIBUTE_BLOCKER_ID)) {
                    $match->setAttribute(Constants::HTML_ATTRIBUTE_BLOCKER_ID, $blocker->getBlockerId());
                }
            }
        }
    }

    /**
     * Prepare the new transformed link attribute.
     *
     * @param AbstractMatch $match
     * @param string $linkAttribute
     * @param string $link
     */
    protected function applyNewLinkElement($match, $linkAttribute, $link) {
        $newLinkAttribute = AttributesHelper::transformAttribute($linkAttribute);

        // Special case: `<embed` needs to have a `src`
        if ($match->isTag('embed') && $linkAttribute === 'src') {
            $match->setAttribute('src', 'about:blank');
            $match->setAttribute($newLinkAttribute, $link);
            return $newLinkAttribute;
        }

        if (in_array($linkAttribute, $this->calculateAllKeepAttributes($match), true)) {
            $match->setAttribute($linkAttribute, $link);
        } else {
            $match->setAttribute($linkAttribute, null);
            $match->setAttribute($newLinkAttribute, $link);
        }

        return $newLinkAttribute;
    }

    /**
     * Calculate all keep-attributes from plugins and configuration.
     *
     * @param AbstractMatch $match
     * @return string[]
     */
    public function calculateAllKeepAttributes($match) {
        $matchPluginCallbacks = MatchPluginCallbacks::getFromMatch($match);

        $cb = $this->getHeadlessContentBlocker();
        $keepAttributes = array_values(
            array_merge($cb->getKeepAlwaysAttributes(), $matchPluginCallbacks->getKeepAlwaysAttributes())
        );

        if ($match->hasAttribute('class')) {
            $classes = explode(' ', $match->getAttribute('class'));
            foreach ($classes as $class) {
                $class = strtolower($class);
                foreach ($cb->getKeepAlwaysAttributesIfClass() as $key => $classKeepAttributes) {
                    if ($class === $key) {
                        $keepAttributes = array_merge($keepAttributes, $classKeepAttributes);
                    }
                }
            }
        }

        $keepAttributes = $this->getHeadlessContentBlocker()->runKeepAlwaysAttributesCallback(
            $keepAttributes,
            $this,
            $match
        );

        return $keepAttributes;
    }

    /**
     * Prepare visual parent depending on class.
     *
     * @param AbstractMatch $match
     */
    protected function applyVisualParent($match) {
        // Short cancel
        if ($match->hasAttribute(Constants::HTML_ATTRIBUTE_VISUAL_PARENT)) {
            return;
        }

        $useVisualParent = false;
        if ($match->hasAttribute('class')) {
            $classes = explode(' ', $match->getAttribute('class'));
            $visualParentIfClass = $this->getHeadlessContentBlocker()->getVisualParentIfClass();
            foreach ($classes as $class) {
                $class = strtolower($class);
                foreach ($visualParentIfClass as $key => $visualParent) {
                    if ($class === $key) {
                        $useVisualParent = $visualParent;
                        break 2;
                    }
                }
            }
        }

        $useVisualParent = $this->getHeadlessContentBlocker()->runVisualParentCallback($useVisualParent, $this, $match);

        if ($useVisualParent !== false) {
            $match->setAttribute(
                Constants::HTML_ATTRIBUTE_VISUAL_PARENT,
                $useVisualParent === true ? 'true' : $useVisualParent
            );
        }
    }

    /**
     * Allows to run hooks on a blocked result instance.
     *
     * @param BlockedResult $result
     * @param AbstractMatch $match
     */
    protected function applyCheckResultHooks($result, $match) {
        $result = $this->getHeadlessContentBlocker()->runCheckResultCallback($result, $this, $match);
        $originalResultIsBlocked = $result->isBlocked();

        // Is the match also covered by another selector syntax?
        $attributes = $match->getAttributes();
        $matchCallbacks = MatchPluginCallbacks::getFromMatch($match);
        foreach (
            $this->getHeadlessContentBlocker()->findPotentialSelectorSyntaxFindersForMatch(
                strtolower($match->getTag()),
                array_keys($attributes)
            )
            as $finder
        ) {
            $matcher = $this->getHeadlessContentBlocker()->getFinderToMatcher()[$finder] ?? null;
            // @codeCoverageIgnoreStart
            if ($matcher === null) {
                continue;
            }
            // @codeCoverageIgnoreEnd

            $matchCallbacks->startTransaction();
            $matchesAttributes = $finder->matchesAttributes($attributes, $match);
            if ($matchesAttributes) {
                $matchCallbacks->commit();
                $applyAttributes = [];
                foreach ($finder->getAttributes() as $attr) {
                    $applyAttributes[$attr->getAttribute()] = $attributes[$attr->getAttribute()];
                }

                if ($matcher instanceof SelectorSyntaxMatcher && $matcher->getBlockable() !== null) {
                    $result->addBlocked($matcher->getBlockable());
                    $result->addBlockedExpression($finder->getExpression());

                    // Beside the "main" match, we need to create a new match instance for the additional result so a plugin like the
                    // scanner can pick it up and show the markup accordingly.
                    if (!$originalResultIsBlocked) {
                        foreach ($finder->getAttributes() as $attr) {
                            $selectorSyntaxMatch = new SelectorSyntaxMatch(
                                $finder,
                                $match->getOriginalMatch(),
                                $match->getTag(),
                                $match->getAttributes(),
                                $attr->getAttribute()
                            );
                            $additionalResult = $this->createPlainResultFromMatch($selectorSyntaxMatch);
                            $additionalResult->addBlocked($matcher->getBlockable());
                            $additionalResult->addBlockedExpression($finder->getExpression());
                            $additionalResult->setData('matcher', $matcher);
                            $additionalResult->setData('match', $selectorSyntaxMatch);
                            $result->addAdditionalResult($additionalResult);
                        }
                    }
                }

                $matchCallbacks->addBlockedMatchCallback(function ($result) use ($match, $applyAttributes) {
                    foreach ($applyAttributes as $attribute => $value) {
                        $this->applyNewLinkElement($match, $attribute, $value);
                    }
                });
            } else {
                $matchCallbacks->rollback();
            }
        }

        return $result;
    }

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

    /**
     * Getter.
     *
     * @return AbstractBlockable[]
     * @codeCoverageIgnore
     */
    public function getBlockables() {
        return $this->getHeadlessContentBlocker()->getBlockables();
    }
}
