from urllib.parse import urlsplit, urlunsplit, parse_qsl
import re

from markupsafe import Markup
import lxml.html
import lxml.etree
import cssutils
import xml.dom

class ValidationError(Exception):
    pass

class DisallowedElement(ValidationError):
    pass

class DisallowedAttribute(ValidationError):
    pass

class DisallowedLink(DisallowedAttribute):
    pass

class DisallowedURLScheme(DisallowedAttribute):
    pass

class CSSSyntaxError(ValidationError):
    pass

ALLOWED_ELEMENTS = {
    # functional:
    'a', 'abbr', 'audio', 'img', 'source',

    # styling:
    'big', 'blockquote', 'code', 'font', 'i', 'tt', 'kbd', 'u', 'var', 'small', 'em', 'strong', 'sub',

    # formatting:
    'br', 'div', 'hr', 'p', 'pre', 'span',

    # lists:
    'dd', 'dl', 'dt', 'li', 'ul', 'ol',

    # headers:
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',

    # tables:
    'table', 'tbody', 'td', 'th', 'thead', 'tr',

    # icons:
    'svg', 'circle', 'path',

    # A special check is applied in :meth:`handle_data` method
    # (only ``.dataframe`` styles allowed, generated from notebook converter)
    'style',
}

ALLOWED_ATTRIBUTES = {
    'class',
    'id',   # XXX: validate id's
    'aria-hidden',
    'title',
    # Also, arbitrary "data-*" attributes are allowed.
}
PER_TAG_ATTRIBUTES = {
    'a': {'href'},
    'img': {'src', 'alt'},
    'font': {'color'},
    'ol': {'start'},
    'svg': {'viewbox'},
    'path': {'d'},
    'circle': {'cx', 'cy', 'r'},
    'audio': {'controls'},
    'source': {'src', 'type'},
    'table': {'border'},
    'td': {'rowspan', 'colspan', 'valign', 'halign'},
    'th': {'rowspan', 'colspan', 'valign', 'halign'},
}

def convert_link(attr_name, value, *, naucse_urls=None):
    url = urlsplit(value)
    if url.scheme in ('http', 'https'):
        if url.netloc == '':
            raise DisallowedLink(value)
        return urlunsplit(url)
    elif url.scheme == '':
        if url.netloc == '':
            # Relative URL
            if url.path.startswith('static/'):
                return url.path
            elif url.path != '':
                # Documents should not assume that naucse has any particular
                # URL structure, so links to other documents aren't allowed.
                raise DisallowedLink(value)
            if url.query:
                # Query arguments are ignored on a static site; disallow them
                raise DisallowedLink(value)
            return urlunsplit(url)
        else:
            return urlunsplit(url)
    elif url.scheme == 'naucse':
        if not naucse_urls:
            raise DisallowedURLScheme(url.scheme)
        query = dict(parse_qsl(url.query))
        for name in query:
            if not re.match(r'^[-_a-z]+$', name):
                raise DisallowedLink(value)
        new_url = naucse_urls[url.path](**query)
        if url.fragment:
            scheme, netloc, path, query, fragment = urlsplit(new_url)
            new_url = urlunsplit((scheme, netloc, path, query, url.fragment))
        return new_url
    elif url.scheme == 'data':
        # XXX: Disallow?
        return value
    else:
        raise DisallowedURLScheme(url.scheme)

    # Should not happen
    raise DisallowedLink(value)


def sanitize_css(css):
    """Return ``css`` limited just to the ``.lesson-content`` element.

    This doesn't protect against malicious input.
    """
    parser = cssutils.CSSParser(
        fetcher=lambda url: None, validate=True, raiseExceptions=True,
    )
    try:
        parsed_css = parser.parseString(css)
    except xml.dom.SyntaxErr as e:
        raise CSSSyntaxError() from e

    for rule in parsed_css.cssRules:
        for selector in rule.selectorList:
            # the space is important - there's a difference between for example
            # ``.lesson-content:hover`` and ``.lesson-content :hover``
            selector.selectorText = ".lesson-content " + selector.selectorText

    return parsed_css.cssText.decode("utf-8")


def sanitize_element(element, *, naucse_urls=None):
    if isinstance(element.tag, str):
        if element.tag == 'style':
            if len(element):
                raise DisallowedElement(list(element)[0])
            if element.text is None:
                element.text = ''
            element.text = sanitize_css(element.text)
        elif element.tag not in ALLOWED_ELEMENTS:
            raise DisallowedElement(element.tag)
    elif element.tag is lxml.etree.Comment:
        pass
    else:
        raise DisallowedElement(element)

    for attr_name, value in list(element.items()):
        if attr_name == 'style':
            # XXX: Sanitize attributes
            # del element.attrib[attr_name]
            continue

        if attr_name.startswith('data-'):
            continue

        if attr_name in {'href', 'src'}:
            element.attrib[attr_name] = convert_link(
                attr_name, value, naucse_urls=naucse_urls)
        elif attr_name == 'scoped':
            # Non-standard, obsolete attribute; see:
            # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/style#Deprecated_attributes
            # We scope CSS in <style> tags in the validator, so the "scoped"
            # attribute would break browsers that still honor it.
            del element.attrib[attr_name]
            continue

        if (
            attr_name not in ALLOWED_ATTRIBUTES
            and attr_name not in PER_TAG_ATTRIBUTES.get(element.tag, ())
        ):
            raise DisallowedAttribute(f'{attr_name} on {element.tag}')

    # Recurse
    for child in element:
        sanitize_element(child, naucse_urls=naucse_urls)


def sanitize_fragment(fragment, *, naucse_urls=None):
    if isinstance(fragment, str):
        return Markup.escape(fragment)
    else:
        sanitize_element(fragment, naucse_urls=naucse_urls)
        return Markup(lxml.etree.tounicode(fragment, method='html'))


def sanitize_html(text, *, naucse_urls=None):
    """Converts untrusted HTML to a naucse-specific form.

    Raises exceptions for potentially dangerous content.
    """

    fragments = [
        sanitize_fragment(fragment, naucse_urls=naucse_urls)
        for fragment in lxml.html.fragments_fromstring(text)
    ]

    return Markup().join(fragments)
