# -*- coding: utf-8 -*-
"""
Created on Fri Jan 4 23:12:02 2019

@author: pourcelo

>>> from websites_test_framework.css_parser import parse
>>> css_content = parse("b {color: blue; font-weight: bold;} p>em {color: red}")
>>> css_content.selectors
['b', 'p>em']
>>> css_content.rules
[Rule(header='b', content=[Property(name='color', value='blue'),
                           Property(name='font-weight', value='bold')]),
 Rule(header='p>em', content=[Property(name='color', value='red')])]
>>> b = css_content.rules[0]
>>> b.properties
[Property(name='color', value='blue'),
 Property(name='font-weight', value='bold')]

"""

import re
from collections import deque
from typing import List, Union, Dict

from websites_test_framework.custom_types import Property, StringMode
from websites_test_framework.tools import is_url_relative, right_replace

RE_INNER_STRING = r"""(?<!\\)('([^']|\\')*(?<!\\)')|("([^"]|\\")*(?<!\\)")"""


def normalize(selector: str) -> str:
    """Remove unnecessary spaces in selectors."""
    # TODO: detect inner strings.
    selector = selector.strip()
    selector = re.sub(r"\s+", " ", selector)
    selector = re.sub(r"\s(?=([>~+]))", "", selector)
    selector = re.sub(r"(?<=([>~+]))\s", "", selector)
    return selector


class CssError(RuntimeError):
    """Invalid CSS."""

    def __str__(self):
        return self.__doc__ if not self.args else self.args[0]


class GenericRule:
    pass


class CssRoot(GenericRule):
    def __init__(self):
        self.content = []


class ParserStack(deque):
    def __init__(self):
        super().__init__()
        self.clear()

    def clear(self) -> None:
        super().clear()
        self.append(CssRoot())

    @property
    def current(self):
        return self[-1]

    @property
    def is_root(self):
        return len(self) == 1


class Rule(GenericRule):
    def __init__(self, header: str, content: List[Union[Property, GenericRule]] = None):
        if content is None:
            content = []
        self.header = normalize(header)
        self.content = list(content)

    @property
    def properties(self):
        return [content for content in self.content if isinstance(content, Property)]

    def __str__(self):
        return f"Rule(header={self.header!r}, content={self.content!r})"

    def __repr__(self):
        return str(self)


class CssSimpleParser:
    """CSS minimalist parser."""

    def __init__(self, string: str = "", verbose: bool = True):
        self.raw: str = string
        self._cached_characters: List[str] = []
        self._stack = ParserStack()
        self._mode: StringMode = None
        self.comments: List[str] = []
        if string:
            self.parse(string, verbose=verbose)

    @property
    def _escape_next_char(self) -> bool:
        return len(self._cached_characters) > 0 and self._cached_characters[-1] == "\\"

    def _get_cached_characters_and_purge(self) -> str:
        string = "".join(self._cached_characters).strip().replace("\n", " ")
        self._cached_characters.clear()
        return string

    def _extracts_comments(self, string: str) -> str:
        """Remove comments in CSS code : /*comment*/."""

        def extract(m):
            self.comments.append(m.group(1))
            return ""

        return re.sub(r"/\*(.*?)\*/", extract, string, flags=re.DOTALL)

    def _reset(self, string: str = ""):
        """Reset state: ready to parse again !"""
        self.raw = string
        self._cached_characters.clear()
        self._stack.clear()
        self._mode = None

    def parse(self, string: str, verbose: bool = True) -> None:
        """Parse CSS code."""
        self._reset(string)
        string = self._extracts_comments(string)

        # scan string.
        append_to_cache = self._cached_characters.append
        stack = self._stack
        property_name = None
        new: Union[Property, Rule, None]
        for char in string:
            if self._mode == "'" or self._mode == '"':
                # we are inside a string
                if not self._escape_next_char and char == self._mode:  # closing string
                    self._mode = None
                append_to_cache(char)
            elif char == ";":  # end of rule or property
                cache = self._get_cached_characters_and_purge().strip()
                if property_name is None:
                    new = Rule(cache) if cache else None
                elif cache.startswith("url(") and ")" not in cache:
                    # Special case for raw image content, like url(data:image/png;base64,...)
                    # the ";" inside url() is not the end of the property value.
                    new = None
                    append_to_cache(cache + ";")
                else:
                    new = Property(property_name, cache)
                    property_name = None
                if new is not None:
                    stack.current.content.append(new)
            elif char == ":" and property_name is None:
                property_name = self._get_cached_characters_and_purge()
            elif char == "{":
                cache = self._get_cached_characters_and_purge()
                if property_name is not None:
                    # This was actually not a property, but probably a pseudo-element
                    # or pseudo-class.
                    new = Rule(f"{property_name}:{cache}")
                    property_name = None
                else:
                    new = Rule(cache)
                stack.current.content.append(new)
                stack.append(new)  # Set this rule as the parent rule for its content.
            elif char == "}":
                if property_name is not None:
                    new = Property(property_name, self._get_cached_characters_and_purge())
                    stack.current.content.append(new)
                    property_name = None
                if stack.is_root:
                    if verbose:
                        self._display_parser_state()
                    raise CssError("No block to close.")
                # Verify that the content of the block is homogeneous:
                # it should not be a mix of Rules and Properties !
                types = {type(elt) for elt in stack.current.content}
                assert len(types) <= 2
                if len(types) == 2:
                    if verbose:
                        print("\n*** Error in this CSS block ***")
                        for elt in stack.current.content:
                            print(elt)
                        print("******\n")
                    raise CssError
                stack.pop()
            else:
                append_to_cache(char)
                if char == "'":
                    self._mode = "'"
                if char == '"':
                    self._mode = '"'

        remaining = "".join(self._cached_characters)
        if remaining.strip():
            if verbose:
                self._display_parser_state(remaining)
            raise CssError

    def _display_parser_state(self, remaining: str = None):
        """Display parser inner state when an error occurs."""
        print("*** Error in this CSS stylesheet ***")
        print("stack:", self._stack)
        print("content:", self.content)
        if remaining is not None:
            print("unparsed:", repr(remaining))
        print("******")

    @property
    def content(self):
        return self._stack[0].content

    @property
    def rules(self) -> List[Rule]:
        """Find recursively all the css rules and return them as a list."""

        def find_rules(content) -> List[Rule]:
            results = []
            for element in content:
                if isinstance(element, Rule):
                    results.append(element)
                    results.extend(find_rules(element.content))
                else:
                    assert isinstance(element, Property)
            return results

        return find_rules(self.content)

    @property
    def properties(self):
        return [css_property for rule in self.rules for css_property in rule.properties]

    @property
    def rules_headers(self) -> List[str]:
        """Find recursively all the css rules' headers and return them as a list.

        For each rule, the rule header is the part preceding the "{...}" block.
        If there is no "{...}" block, which may happen for some @rules, the whole rule
        is considered to be a header.

        While most headers are selectors, this is not true for @rules.
        """
        return [rule.header for rule in self.rules]

    @property
    def selectors(self) -> List[str]:
        return [header for header in self.rules_headers if not header.startswith("@")]

    @property
    def at_rules_names(self) -> List[str]:
        return [header.split()[0] for header in self.rules_headers if header.startswith("@")]

    def has_selector(self, selector: str) -> bool:
        return normalize(selector) in self.selectors

    def find_all(self, selector: str) -> List[Rule]:
        """Return a list of all rules involving this selector."""
        selector = normalize(selector)
        return [rule for rule in self.rules if rule.header == selector]

    def list_imports(self, absolute=False) -> List[str]:
        headers = [rule.header for rule in self.rules if rule.header.startswith("@import ")]
        urls = []
        for header in headers:
            m = re.search(RE_INNER_STRING, header)
            if m is None:
                raise CssError(f"Invalid @import rule: {header!r}.")
            url = m.group()[1:-1].strip()
            if is_url_relative(url) or absolute:
                urls.append(url)
        return urls

    def get_properties(self, selector: str) -> Dict[str, str]:
        """Return a dictionary of properties for this selector."""
        d: Dict[str, str] = {}
        selector = normalize(selector)
        for rule in self.rules:
            if rule.header == selector:
                d.update(rule.properties)
        return d

    # def add_rule(self, header, properties):
    #     header = normalize(header)
    #     for sel in header.split(','):
    #         self.rules.append(Rule(sel, properties))


def looks_like_css_code(text: str) -> bool:
    """Inspect text and test if it looks like a bit of CSS code.

    This is used to distinguish real CSS comments from disabled CSS code.
    It uses only basic heuristic, so do not expect too much from it. ;)
    """
    try:
        parse(text, verbose=False)
        return True
    except CssError:
        opening_brackets = text.count("{")
        closing_brackets = text.count("}")
        unclosed = opening_brackets - closing_brackets
        if unclosed > 0:
            text += unclosed * "}"
        elif unclosed < 0:
            text = right_replace(text, "}", "", abs(unclosed))
        try:
            parse(text, verbose=False)
            return True
        except CssError:
            return False
        # return any(re.match("[^:]+:[^;]+;$", line.strip()) for line in text.split("\n"))


def parse(string: str, verbose: bool = True) -> CssSimpleParser:
    return CssSimpleParser(string, verbose=verbose)


if __name__ == "__main__":
    css = parse("p + p { color: red; border: solid 2px }\n @media (print) {p + p {color: yellow;}}")
    print(css.selectors)
