<?php

declare(strict_types=1);

namespace Pelago\Emogrifier\Css;

use Pelago\Emogrifier\Utilities\Preg;
use Sabberworm\CSS\CSSList\AtRuleBlockList as CssAtRuleBlockList;
use Sabberworm\CSS\CSSList\Document as SabberwormCssDocument;
use Sabberworm\CSS\Parser as CssParser;
use Sabberworm\CSS\Property\AtRule as CssAtRule;
use Sabberworm\CSS\Property\Charset as CssCharset;
use Sabberworm\CSS\Property\Import as CssImport;
use Sabberworm\CSS\Renderable as CssRenderable;
use Sabberworm\CSS\RuleSet\DeclarationBlock as CssDeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet as CssRuleSet;
use Sabberworm\CSS\Settings as ParserSettings;

/**
 * Parses and stores a CSS document from a string of CSS, and provides methods to obtain the CSS in parts or as data
 * structures.
 *
 * @internal
 */
final class CssDocument
{
    /**
     * @var SabberwormCssDocument
     */
    private $sabberwormCssDocument;

    /**
     * `@import` rules must precede all other types of rules, except `@charset` rules.  This property is used while
     * rendering at-rules to enforce that.
     *
     * @var bool
     */
    private $isImportRuleAllowed = true;

    /**
     * @param string $css
     * @param bool $debug
     *        If this is `true`, an exception will be thrown if invalid CSS is encountered.
     *        Otherwise the parser will try to do the best it can.
     */
    public function __construct(string $css, bool $debug)
    {
        // CSS Parser currently throws exception with nested at-rules (like `@media`) in strict parsing mode
        $parserSettings = ParserSettings::create()->withLenientParsing(!$debug || $this->hasNestedAtRule($css));

        // CSS Parser currently throws exception with non-empty whitespace-only CSS in strict parsing mode, so `trim()`
        // @see https://github.com/sabberworm/PHP-CSS-Parser/issues/349
        $this->sabberwormCssDocument = (new CssParser(\trim($css), $parserSettings))->parse();
    }

    /**
     * Tests if a string of CSS appears to contain an at-rule with nested rules
     * (`@media`, `@supports`, `@keyframes`, `@document`,
     * the latter two additionally with vendor prefixes that may commonly be used).
     *
     * @see https://github.com/sabberworm/PHP-CSS-Parser/issues/127
     */
    private function hasNestedAtRule(string $css): bool
    {
        return (new Preg())
            ->match('/@(?:media|supports|(?:-webkit-|-moz-|-ms-|-o-)?+(keyframes|document))\\b/', $css) !== 0;
    }

    /**
     * Collates the media query, selectors and declarations for individual rules from the parsed CSS, in order.
     *
     * @param array<array-key, string> $allowedMediaTypes
     *
     * @return list<StyleRule>
     */
    public function getStyleRulesData(array $allowedMediaTypes): array
    {
        $ruleMatches = [];
        /** @var CssRenderable $rule */
        foreach ($this->sabberwormCssDocument->getContents() as $rule) {
            if ($rule instanceof CssAtRuleBlockList) {
                $containingAtRule = $this->getFilteredAtIdentifierAndRule($rule, $allowedMediaTypes);
                if (\is_string($containingAtRule)) {
                    /** @var CssRenderable $nestedRule */
                    foreach ($rule->getContents() as $nestedRule) {
                        if ($nestedRule instanceof CssDeclarationBlock) {
                            $ruleMatches[] = new StyleRule($nestedRule, $containingAtRule);
                        }
                    }
                }
            } elseif ($rule instanceof CssDeclarationBlock) {
                $ruleMatches[] = new StyleRule($rule);
            }
        }

        return $ruleMatches;
    }

    /**
     * Renders at-rules from the parsed CSS that are valid and not conditional group rules (i.e. not rules such as
     * `@media` which contain style rules whose data is returned by {@see getStyleRulesData}).  Also does not render
     * `@charset` rules; these are discarded (only UTF-8 is supported).
     *
     * @return string
     */
    public function renderNonConditionalAtRules(): string
    {
        $this->isImportRuleAllowed = true;
        $cssContents = $this->sabberwormCssDocument->getContents();
        $atRules = \array_filter($cssContents, [$this, 'isValidAtRuleToRender']);

        if ($atRules === []) {
            return '';
        }

        $atRulesDocument = new SabberwormCssDocument();
        $atRulesDocument->setContents($atRules);

        return $atRulesDocument->render();
    }

    /**
     * @param CssAtRuleBlockList $rule
     * @param array<array-key, string> $allowedMediaTypes
     *
     * @return ?string
     *         If the nested at-rule is supported, it's opening declaration (e.g. "@media (max-width: 768px)") is
     *         returned; otherwise the return value is null.
     */
    private function getFilteredAtIdentifierAndRule(CssAtRuleBlockList $rule, array $allowedMediaTypes): ?string
    {
        $result = null;

        if ($rule->atRuleName() === 'media') {
            $mediaQueryList = $rule->atRuleArgs();
            [$mediaType] = \explode('(', $mediaQueryList, 2);
            if (\trim($mediaType) !== '') {
                $escapedAllowedMediaTypes = \array_map(
                    static function (string $allowedMediaType): string {
                        return \preg_quote($allowedMediaType, '/');
                    },
                    $allowedMediaTypes
                );
                $mediaTypesMatcher = \implode('|', $escapedAllowedMediaTypes);
                $isAllowed
                    = (new Preg())->match('/^\\s*+(?:only\\s++)?+(?:' . $mediaTypesMatcher . ')/i', $mediaType) !== 0;
            } else {
                $isAllowed = true;
            }

            if ($isAllowed) {
                $result = '@media ' . $mediaQueryList;
            }
        }

        return $result;
    }

    /**
     * Tests if a CSS rule is an at-rule that should be passed though and copied to a `<style>` element unmodified:
     * - `@charset` rules are discarded - only UTF-8 is supported - `false` is returned;
     * - `@import` rules are passed through only if they satisfy the specification ("user agents must ignore any
     *   '@import' rule that occurs inside a block or after any non-ignored statement other than an '@charset' or an
     *   '@import' rule");
     * - `@media` rules are processed separately to see if their nested rules apply - `false` is returned;
     * - `@font-face` rules are checked for validity - they must contain both a `src` and `font-family` property;
     * - other at-rules are assumed to be valid and treated as a black box - `true` is returned.
     *
     * @param CssRenderable $rule
     *
     * @return bool
     */
    private function isValidAtRuleToRender(CssRenderable $rule): bool
    {
        if ($rule instanceof CssCharset) {
            return false;
        }

        if ($rule instanceof CssImport) {
            return $this->isImportRuleAllowed;
        }

        $this->isImportRuleAllowed = false;

        if (!$rule instanceof CssAtRule) {
            return false;
        }

        switch ($rule->atRuleName()) {
            case 'media':
                $result = false;
                break;
            case 'font-face':
                $result = $rule instanceof CssRuleSet
                    && $rule->getRules('font-family') !== []
                    && $rule->getRules('src') !== [];
                break;
            default:
                $result = true;
        }

        return $result;
    }
}
