<?php

declare(strict_types=1);

namespace Sabberworm\CSS\CSSList;

use Sabberworm\CSS\Comment\CommentContainer;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Property\AtRule;
use Sabberworm\CSS\Property\Charset;
use Sabberworm\CSS\Property\CSSNamespace;
use Sabberworm\CSS\Property\Import;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\RuleSet\AtRuleSet;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet;
use Sabberworm\CSS\Value\CSSString;
use Sabberworm\CSS\Value\URL;
use Sabberworm\CSS\Value\Value;

use function Safe\preg_match;

/**
 * This is the most generic container available. It can contain `DeclarationBlock`s (rule sets with a selector),
 * `RuleSet`s as well as other `CSSList` objects.
 *
 * It can also contain `Import` and `Charset` objects stemming from at-rules.
 *
 * Note that `CSSListItem` extends both `Commentable` and `Renderable`,
 * so those interfaces must also be implemented by concrete subclasses.
 */
abstract class CSSList implements CSSElement, CSSListItem, Positionable
{
    use CommentContainer;
    use Position;

    /**
     * @var array<int<0, max>, CSSListItem>
     *
     * @internal since 8.8.0
     */
    protected $contents = [];

    /**
     * @param int<1, max>|null $lineNumber
     */
    public function __construct(?int $lineNumber = null)
    {
        $this->setPosition($lineNumber);
    }

    /**
     * @throws UnexpectedTokenException
     * @throws SourceException
     *
     * @internal since V8.8.0
     */
    public static function parseList(ParserState $parserState, CSSList $list): void
    {
        $isRoot = $list instanceof Document;
        $usesLenientParsing = $parserState->getSettings()->usesLenientParsing();
        $comments = [];
        while (!$parserState->isEnd()) {
            $parserState->consumeWhiteSpace($comments);
            $listItem = null;
            if ($usesLenientParsing) {
                try {
                    $positionBeforeParse = $parserState->currentColumn();
                    $listItem = self::parseListItem($parserState, $list);
                } catch (UnexpectedTokenException $e) {
                    $listItem = false;
                    // If the failed parsing did not consume anything that was to come ...
                    if ($parserState->currentColumn() === $positionBeforeParse && !$parserState->isEnd()) {
                        // ... the unexpected token needs to be skipped, otherwise there'll be an infinite loop.
                        $parserState->consume(1);
                    }
                }
            } else {
                $listItem = self::parseListItem($parserState, $list);
            }
            if ($listItem === null) {
                // List parsing finished
                return;
            }
            if ($listItem) {
                $listItem->addComments($comments);
                $list->append($listItem);
            }
            $comments = [];
            $parserState->consumeWhiteSpace($comments);
        }
        $list->addComments($comments);
        if (!$isRoot && !$usesLenientParsing) {
            throw new SourceException('Unexpected end of document', $parserState->currentLine());
        }
    }

    /**
     * @return CSSListItem|false|null
     *         If `null` is returned, it means the end of the list has been reached.
     *         If `false` is returned, it means an invalid item has been encountered,
     *         but parsing of the next item should proceed.
     *
     * @throws SourceException
     * @throws UnexpectedEOFException
     * @throws UnexpectedTokenException
     */
    private static function parseListItem(ParserState $parserState, CSSList $list)
    {
        $isRoot = $list instanceof Document;
        if ($parserState->comes('@')) {
            $atRule = self::parseAtRule($parserState);
            if ($atRule instanceof Charset) {
                if (!$isRoot) {
                    throw new UnexpectedTokenException(
                        '@charset may only occur in root document',
                        '',
                        'custom',
                        $parserState->currentLine()
                    );
                }
                if (\count($list->getContents()) > 0) {
                    throw new UnexpectedTokenException(
                        '@charset must be the first parseable token in a document',
                        '',
                        'custom',
                        $parserState->currentLine()
                    );
                }
                $parserState->setCharset($atRule->getCharset());
            }
            return $atRule;
        } elseif ($parserState->comes('}')) {
            if ($isRoot) {
                if ($parserState->getSettings()->usesLenientParsing()) {
                    $parserState->consume(1);
                    return self::parseListItem($parserState, $list);
                } else {
                    throw new SourceException('Unopened {', $parserState->currentLine());
                }
            } else {
                // End of list
                return null;
            }
        } else {
            return DeclarationBlock::parse($parserState, $list) ?? false;
        }
    }

    /**
     * @throws SourceException
     * @throws UnexpectedTokenException
     * @throws UnexpectedEOFException
     */
    private static function parseAtRule(ParserState $parserState): ?CSSListItem
    {
        $parserState->consume('@');
        $identifier = $parserState->parseIdentifier();
        $identifierLineNumber = $parserState->currentLine();
        $parserState->consumeWhiteSpace();
        if ($identifier === 'import') {
            $location = URL::parse($parserState);
            $parserState->consumeWhiteSpace();
            $mediaQuery = null;
            if (!$parserState->comes(';')) {
                $mediaQuery = \trim($parserState->consumeUntil([';', ParserState::EOF]));
                if ($mediaQuery === '') {
                    $mediaQuery = null;
                }
            }
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
            return new Import($location, $mediaQuery, $identifierLineNumber);
        } elseif ($identifier === 'charset') {
            $charsetString = CSSString::parse($parserState);
            $parserState->consumeWhiteSpace();
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
            return new Charset($charsetString, $identifierLineNumber);
        } elseif (self::identifierIs($identifier, 'keyframes')) {
            $result = new KeyFrame($identifierLineNumber);
            $result->setVendorKeyFrame($identifier);
            $result->setAnimationName(\trim($parserState->consumeUntil('{', false, true)));
            CSSList::parseList($parserState, $result);
            if ($parserState->comes('}')) {
                $parserState->consume('}');
            }
            return $result;
        } elseif ($identifier === 'namespace') {
            $prefix = null;
            $url = Value::parsePrimitiveValue($parserState);
            if (!$parserState->comes(';')) {
                $prefix = $url;
                $url = Value::parsePrimitiveValue($parserState);
            }
            $parserState->consumeUntil([';', ParserState::EOF], true, true);
            if ($prefix !== null && !\is_string($prefix)) {
                throw new UnexpectedTokenException('Wrong namespace prefix', $prefix, 'custom', $identifierLineNumber);
            }
            if (!($url instanceof CSSString || $url instanceof URL)) {
                throw new UnexpectedTokenException(
                    'Wrong namespace url of invalid type',
                    $url,
                    'custom',
                    $identifierLineNumber
                );
            }
            return new CSSNamespace($url, $prefix, $identifierLineNumber);
        } else {
            // Unknown other at rule (font-face or such)
            $arguments = \trim($parserState->consumeUntil('{', false, true));
            if (\substr_count($arguments, '(') !== \substr_count($arguments, ')')) {
                if ($parserState->getSettings()->usesLenientParsing()) {
                    return null;
                } else {
                    throw new SourceException('Unmatched brace count in media query', $parserState->currentLine());
                }
            }
            $useRuleSet = true;
            foreach (\explode('/', AtRule::BLOCK_RULES) as $blockRuleName) {
                if (self::identifierIs($identifier, $blockRuleName)) {
                    $useRuleSet = false;
                    break;
                }
            }
            if ($useRuleSet) {
                $atRule = new AtRuleSet($identifier, $arguments, $identifierLineNumber);
                RuleSet::parseRuleSet($parserState, $atRule);
            } else {
                $atRule = new AtRuleBlockList($identifier, $arguments, $identifierLineNumber);
                CSSList::parseList($parserState, $atRule);
                if ($parserState->comes('}')) {
                    $parserState->consume('}');
                }
            }
            return $atRule;
        }
    }

    /**
     * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed.
     * We need to check for these versions too.
     */
    private static function identifierIs(string $identifier, string $match): bool
    {
        if (\strcasecmp($identifier, $match) === 0) {
            return true;
        }

        return preg_match("/^(-\\w+-)?$match$/i", $identifier) === 1;
    }

    /**
     * Prepends an item to the list of contents.
     */
    public function prepend(CSSListItem $item): void
    {
        \array_unshift($this->contents, $item);
    }

    /**
     * Appends an item to the list of contents.
     */
    public function append(CSSListItem $item): void
    {
        $this->contents[] = $item;
    }

    /**
     * Splices the list of contents.
     *
     * @param array<int, CSSListItem> $replacement
     */
    public function splice(int $offset, ?int $length = null, ?array $replacement = null): void
    {
        \array_splice($this->contents, $offset, $length, $replacement);
    }

    /**
     * Inserts an item in the CSS list before its sibling. If the desired sibling cannot be found,
     * the item is appended at the end.
     */
    public function insertBefore(CSSListItem $item, CSSListItem $sibling): void
    {
        if (\in_array($sibling, $this->contents, true)) {
            $this->replace($sibling, [$item, $sibling]);
        } else {
            $this->append($item);
        }
    }

    /**
     * Removes an item from the CSS list.
     *
     * @param CSSListItem $itemToRemove
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`,
     *        a `Charset` or another `CSSList` (most likely a `MediaQuery`)
     *
     * @return bool whether the item was removed
     */
    public function remove(CSSListItem $itemToRemove): bool
    {
        $key = \array_search($itemToRemove, $this->contents, true);
        if ($key !== false) {
            unset($this->contents[$key]);
            return true;
        }

        return false;
    }

    /**
     * Replaces an item from the CSS list.
     *
     * @param CSSListItem $oldItem
     *        May be a `RuleSet` (most likely a `DeclarationBlock`), an `Import`, a `Charset`
     *        or another `CSSList` (most likely a `MediaQuery`)
     * @param CSSListItem|array<CSSListItem> $newItem
     */
    public function replace(CSSListItem $oldItem, $newItem): bool
    {
        $key = \array_search($oldItem, $this->contents, true);
        if ($key !== false) {
            if (\is_array($newItem)) {
                \array_splice($this->contents, $key, 1, $newItem);
            } else {
                \array_splice($this->contents, $key, 1, [$newItem]);
            }
            return true;
        }

        return false;
    }

    /**
     * @param array<int, CSSListItem> $contents
     */
    public function setContents(array $contents): void
    {
        $this->contents = [];
        foreach ($contents as $content) {
            $this->append($content);
        }
    }

    /**
     * Removes a declaration block from the CSS list if it matches all given selectors.
     *
     * @param DeclarationBlock|array<Selector>|string $selectors the selectors to match
     * @param bool $removeAll whether to stop at the first declaration block found or remove all blocks
     */
    public function removeDeclarationBlockBySelector($selectors, bool $removeAll = false): void
    {
        if ($selectors instanceof DeclarationBlock) {
            $selectors = $selectors->getSelectors();
        }
        if (!\is_array($selectors)) {
            $selectors = \explode(',', $selectors);
        }
        foreach ($selectors as $key => &$selector) {
            if (!($selector instanceof Selector)) {
                if (!Selector::isValid($selector)) {
                    throw new UnexpectedTokenException(
                        "Selector did not match '" . Selector::SELECTOR_VALIDATION_RX . "'.",
                        $selector,
                        'custom'
                    );
                }
                $selector = new Selector($selector);
            }
        }
        foreach ($this->contents as $key => $item) {
            if (!($item instanceof DeclarationBlock)) {
                continue;
            }
            if (self::selectorsMatch($item->getSelectors(), $selectors)) {
                unset($this->contents[$key]);
                if (!$removeAll) {
                    return;
                }
            }
        }
    }

    protected function renderListContents(OutputFormat $outputFormat): string
    {
        $result = '';
        $isFirst = true;
        $nextLevelFormat = $outputFormat;
        if (!$this->isRootList()) {
            $nextLevelFormat = $outputFormat->nextLevel();
        }
        $nextLevelFormatter = $nextLevelFormat->getFormatter();
        $formatter = $outputFormat->getFormatter();
        foreach ($this->contents as $listItem) {
            $renderedCss = $formatter->safely(static function () use ($nextLevelFormat, $listItem): string {
                return $listItem->render($nextLevelFormat);
            });
            if ($renderedCss === null) {
                continue;
            }
            if ($isFirst) {
                $isFirst = false;
                $result .= $nextLevelFormatter->spaceBeforeBlocks();
            } else {
                $result .= $nextLevelFormatter->spaceBetweenBlocks();
            }
            $result .= $renderedCss;
        }

        if (!$isFirst) {
            // Had some output
            $result .= $formatter->spaceAfterBlocks();
        }

        return $result;
    }

    /**
     * Return true if the list can not be further outdented. Only important when rendering.
     */
    abstract public function isRootList(): bool;

    /**
     * Returns the stored items.
     *
     * @return array<int<0, max>, CSSListItem>
     */
    public function getContents(): array
    {
        return $this->contents;
    }

    /**
     * @return array<string, bool|int|float|string|array<mixed>|null>
     *
     * @internal
     */
    public function getArrayRepresentation(): array
    {
        throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`');
    }

    /**
     * @param list<Selector> $selectors1
     * @param list<Selector> $selectors2
     */
    private static function selectorsMatch(array $selectors1, array $selectors2): bool
    {
        $selectorStrings1 = self::getSelectorStrings($selectors1);
        $selectorStrings2 = self::getSelectorStrings($selectors2);

        \sort($selectorStrings1);
        \sort($selectorStrings2);

        return $selectorStrings1 === $selectorStrings2;
    }

    /**
     * @param list<Selector> $selectors
     *
     * @return list<string>
     */
    private static function getSelectorStrings(array $selectors): array
    {
        return \array_map(
            static function (Selector $selector): string {
                return $selector->getSelector();
            },
            $selectors
        );
    }
}
