import bs4.dammit
import bs4.formatter
from bs4 import BeautifulSoup, Tag, NavigableString
from loguru import logger


EVIL_TAGNAMES = {"p": {"img", "ul"}}
# EVIL_TAGNAMES = {"p": {"ul"}, "ALL": {"img"}}

"""
    PwnDoc requires very specific formatting to:
        - display elements as elements, not just text
        - output them into docx audit 
    This requires many PwnDoc specific steps, if we want to import anything that did not originally come from PwnDoc.
    For some of the requirements listed bellow we only preserve them, but don't fix them if they are broken.

    Some of the PwnDoc requirements are:
        - tags can't end with />, e.g. no <br/> or <img />
        - the order of attributes in tags is important, e.g. <img src="6352e52b5137a10013dd06f2" alt="example.jpg">
        - some elements can't be inside other, e.g. <br> can't be inside <ul>
        - there cannot be any \n
        - docx output contains any trailing (or starting) whitespaces, while HTML would ignore them. So they are important to preserve.
        - some tags need to be normalized, e.g. <b> to <strong>
        - only some HTML tags are supported, e.g. links have to be extracted and placed as text
        - no top-level text without enclosing <p> tag (or similar)

    The BeautifulSoup has some formatters, but they are problematic:
        - formatter None adds random spaces (param indent)
        - formatter minimal would escape <, >, & (which we want) but also adds random spaces
        - formatter html5 omits closing slash in HTML void tags like “br”, but also adds add random spaces
    
    Also, we can't use prettify, because it adds \n (and other whitespaces), to which PwnDoc is sensitive.
    We also can't use str(bs), because by default it orders tag attributes.
        Using bs.encode is preferred, there is a helper function for that PwnDocFormatterInstance.to_text(br).
    
    The following documentation shows how to create custom output formatter.       
        - https://www.crummy.com/software/BeautifulSoup/bs4/doc/#output-formatters
        - https://bazaar.launchpad.net/~leonardr/beautifulsoup/bs4/view/head:/bs4/formatter.py#L166
        - https://tedboy.github.io/bs4_doc/8_output.html
"""


class PwnDocFormatter(bs4.formatter.HTMLFormatter):
    def __init__(self):
        super().__init__(
            entity_substitution=bs4.dammit.EntitySubstitution.substitute_xml,  # xml substitution is equal to html formatter minimal
            void_element_close_prefix="",
            # empty_attributes_are_booleans=True,
            indent=0,  # indent is supported from BeautifulSoup 4.11.0
        )

    def attributes(self, tag):
        # Don't sort attributes by name
        for k, v in tag.attrs.items():
            yield k, v

    def to_text(self, bs: BeautifulSoup) -> str:
        try:
            return bs.decode(formatter=self)
        except:
            logger.warning(f"bs4PwnDocFormatter failed to decode. Fallback on str(x). The x was of type: {type(bs)}")
            return str(bs)


PwnDocFormatterInstance = PwnDocFormatter()


def pwndoc_extract_tags_from_forbidden_parents(soup: BeautifulSoup) -> str:
    bs_soups = move_evil_tags_out(EVIL_TAGNAMES, soup)
    soups = [PwnDocFormatterInstance.to_text(v) for v in bs_soups]
    new_text = "".join(soups)
    return new_text


# adapted from https://stackoverflow.com/questions/23057631/clone-element-with-beautifulsoup
def copy_tag_with_attributes(el):
    if isinstance(el, NavigableString):
        return type(el)(el)

    copy = Tag(None, el.builder, el.name, el.namespace, el.nsprefix)
    # work around bug where there is no builder set
    # https://bugs.launchpad.net/beautifulsoup/+bug/1307471
    copy.attrs = dict(el.attrs)
    for attr in ('can_be_empty_element', 'hidden'):
        setattr(copy, attr, getattr(el, attr))
    return copy


def move_evil_tags_out(evil_tagnames, el):
    def is_evil_tag(parent, element):
        return isinstance(element, Tag) and (
            element.name in evil_tagnames.get("ALL", set())
            or (parent is not None and element.name in evil_tagnames.get(parent.name, set()))
        )

    def extract_range(children_result, from_, to_):
        copy_el = copy_tag_with_attributes(el)
        copy_el.extend(children_result[from_:to_])
        return copy_el

    if isinstance(el, NavigableString):
        return [el] if el.string.strip() else []

    children_result = [ch_r for ch in el.children for ch_r in move_evil_tags_out(evil_tagnames, ch)]

    if not any(is_evil_tag(el, ch_r) or el != ch_r.parent for ch_r in children_result):
        return [el]

    result = []
    next_index = 0
    for i, r in enumerate(children_result):
        if is_evil_tag(el, r):
            if next_index < i:
                result.append(extract_range(children_result, next_index, i))
            result.append(r)
            next_index = i + 1

    if next_index < len(children_result):
        result.append(extract_range(children_result, next_index, len(children_result)))

    return result


def wrap_bare_words(el):
    assert el.parent is None
    for child in list(el.children):
        if not isinstance(child, NavigableString) and child and child.name is None:
            continue

        # Todo: is collapsing multiple <br> into one new <p> a desired behaviour?
        if child.name == 'br' and child.nextSibling and child.nextSibling.name == 'br':
            while child.nextSibling and child.nextSibling.name == 'br':
                child.nextSibling.decompose()
            child.replace_with(el.new_tag("p"))
            continue

        if isinstance(child, NavigableString) or child.name == 'br':
            previous_el = child.previousSibling
            if previous_el and previous_el.name == 'p':
                previous_el.append(child.extract())
                continue

        if isinstance(child, NavigableString) or child.name in ['strong', 'em', 'u', 's', 'code']:  # todo: check this list of elements
            child.wrap(el.new_tag("p"))
    return el
