import re
import typing

import mwparserfromhell as mwhell
import mwparserfromhtml as mwhtml

from mwedittypes.tokenizer import parse_change_text
from mwedittypes.utils import (
    find_nested_media,
    node_to_name,
    sec_to_name,
    simple_node_class,
    strip_tag_to_core,
    wikistew_to_plaintext,
    wikitext_to_plaintext,
)


# equivalent of main function
def get_diff(prev_content: str, curr_content: str, content_type: str, lang: str = "en"):
    """Run through full process of getting diff between two wikitext revisions."""
    prev_tree = ContentBag(content=prev_content, content_type=content_type, lang=lang)
    curr_tree = ContentBag(content=curr_content, content_type=content_type, lang=lang)
    d = Differ(prev_tree, curr_tree)
    result = d.count_actions()
    return result


class WikitextNode:
    """
    Basic object for wrapping mwparserfromhell wikitext nodes
    """

    def __init__(
        self,
        name: str,
        ntype: str = "Text",
        # I'm abusing mwnode a bit here -- probably should rethink how to make this more predictable
        mwnode: typing.Union[mwhell.wikicode.StringMixIn, str] = "",
        section: str = "",
        lang: str = "en",
    ):
        self.name = name  # For debugging purposes
        self.ntype = ntype  # Type of node for result
        self.mwnode = mwnode
        self.content_hash = hash(str(mwnode))
        self.section = section  # section that the node is a part of -- useful for formatting final diff
        self.lang = lang

    def unnest(self):
        """Expand a section into all of its subcomponent nodes.

        This approach starts with a Section node. It goes through the direct children (recursive=False)
        first. It then loops through each child's full descendant list (recursive=True) and adds all
        non-text nodes to the node list. Ideally we'd want a recursive function that unnests level-by-level
        because that'd be simpler. But this flattened, two-stage approach is necessary for a few reasons:
        * We want to look for media in Text nodes nested within galleries/templates but the fully recursive
         search does not make it easy to track the parent relationship. Galleries also need special re-parsing
         to correctly identify any nested wikilinks etc. (otherwise treated as text).
        * mwparserfromhtml will return just direct children for Section nodes if you call .filter(recursive=False)
        but doing the same on any other object just returns itself. Switching to .filter(recursive=True) returns
        all descendants, which would result in duplicates if we made unnest into a recursive function. This
        is not easily solveable unfortunately.

        As a result, this function only works when called on section nodes, which we enforce via isinstance check.

        Ultimately, if we had a Section with a <ref> tag in it like so:
        <ref>{{cite web|title=[[Gallery]]|url=http://digital.belvedere.at|publisher=Digitales Belvedere}}</ref>

        We'd want our final node list to include entities for the reference, cite template, gallery wikilink, and URL.
        """
        # make sure working with a Section node, which is natively a WikiCode object
        nodes = []
        if isinstance(self.mwnode, mwhell.wikicode.Wikicode):
            # handle text-formatting separately; we skip it when parsing because it can
            # trigger more substantive parsing errors:
            # https://github.com/earwig/mwparserfromhell/issues/134
            section_wikitext = str(self.mwnode)
            if "''" in section_wikitext:
                for line in section_wikitext.split("\n"):
                    if "''" in line:
                        line, bt_found = re.subn("'{5}", "", line)
                        for _ in range(bt_found // 2):
                            nodes.append(
                                WikitextNode(
                                    "Bold-Italic",
                                    ntype="Text Formatting",
                                    mwnode="'''''",
                                    section=self.section,
                                    lang=self.lang,
                                )
                            )
                        line, b_found = re.subn("'{3}", "", line)
                        for _ in range(b_found // 2):
                            nodes.append(
                                WikitextNode(
                                    "Bold",
                                    ntype="Text Formatting",
                                    mwnode="'''",
                                    section=self.section,
                                    lang=self.lang,
                                )
                            )
                        line, t_found = re.subn("'{2}", "", line)
                        for _ in range(t_found // 2):
                            nodes.append(
                                WikitextNode(
                                    "Italic",
                                    ntype="Text Formatting",
                                    mwnode="''",
                                    section=self.section,
                                    lang=self.lang,
                                )
                            )
            # go through first level of children
            for n in self.mwnode.ifilter(recursive=False):
                ntype = simple_node_class(n, self.lang)
                if ntype != "Text":
                    n_node = WikitextNode(
                        node_to_name(n, self.lang),
                        ntype=ntype,
                        mwnode=n,
                        section=self.section,
                        lang=self.lang,
                    )
                    nodes.append(n_node)

                    if n_node.ntype == "Gallery":
                        # strip leading / trailing gallery tags so parser correctly parses everything in between
                        # otherwise links, templates, etc. is treated as text
                        gallery_wikitext = str(n_node.mwnode)
                        gallery_start = gallery_wikitext.find(">")
                        gallery_end = gallery_wikitext.rfind("<")
                        try:
                            # the break is a hack; it'll be skipped in the ifilter loop; otherwise first image skipped
                            wt = mwhell.parse(
                                "<br>"
                                + gallery_wikitext[gallery_start + 1 : gallery_end],
                                skip_style_tags=True,
                            )
                        except Exception:  # fallback
                            wt = mwhell.parse(n_node.mwnode)
                    # mwhell.parse on parsed node automatically carries over skip_style_tags and doesn't re-parse
                    else:
                        wt = mwhell.parse(n_node.mwnode)
                    # now go through sub-nodes
                    for idx, nn in enumerate(wt.ifilter(recursive=True)):
                        if idx == 0:
                            continue  # skip root node -- already set or placeholder <br> node for galleries
                        nntype = simple_node_class(nn, self.lang)
                        if nntype == "Text":
                            # Text node with a parent that is template or gallery:
                            # media w/o bracket will be IDed as text by mwparserfromhell
                            # and templates / galleries are where we find this nested media.
                            # image names will be found no guarantees it'll have caption.
                            if n_node.ntype == "Template" or n_node.ntype == "Gallery":
                                for m in find_nested_media(
                                    str(nn), is_gallery=(n_node.ntype == "Gallery")
                                ):
                                    nodes.append(
                                        WikitextNode(
                                            f"Media: {m[:10]}...",
                                            ntype="Media",
                                            mwnode=m,
                                            section=n_node.section,
                                            lang=self.lang,
                                        )
                                    )
                        # tables are very highly-structured and produce a ton of nodes (each cell and more)
                        # so we just extract links, formatting, etc. that appears in the table and skip the cells
                        # because changes to those will generally be caught in the overall table/text changes
                        # this leads to much faster parsing in exchange for not knowing how many cells were edited
                        elif nntype == "Table Element":
                            pass
                        else:
                            nodes.append(
                                WikitextNode(
                                    node_to_name(nn, lang=self.lang),
                                    ntype=nntype,
                                    mwnode=nn,
                                    section=n_node.section,
                                    lang=self.lang,
                                )
                            )
        return nodes


class HTMLNode:
    """
    Basic object for wrapping mwparserfromhtml html nodes
    """

    def __init__(self, name: str, ntype: str, mwnode: mwhtml.WikiStew, section: str):
        self.name = name  # For debugging purposes
        self.ntype = ntype  # Type of node for result
        self.mwnode = mwnode
        self.content_hash = hash(str(self.mwnode.tag))
        self.section = section  # section that the node is a part of -- useful for formatting final diff

    def unnest(self):
        """Expand a node to also include all of its subnodes.
        This approach starts with a single HTML node -- e.g., a paragraph with various wikilinks in it.
        and splits it into its component parts (text, wikilink, text, wikilink, text) to find fine-grained changes.
        """
        nodes = []
        for tag in self.mwnode.tag.descendants:
            if hasattr(tag, "attrs"):
                ntype = mwhtml.parse.plaintext._tag_to_element(tag)
                if ntype:
                    tag_start = tag.text.replace("\n", "\\n")[:10]
                    subnode = HTMLNode(
                        name=f"{ntype} ({tag_start})",
                        ntype=ntype,
                        mwnode=mwhtml.WikiStew(tag),
                        section=self.section,
                    )
                    nodes.append(subnode)
        return nodes


class ContentBag:
    """
    Structure for extracting and holding an unordered collection of nodes based on:
    * mwparserfromhell for wikitext
    * mwparserfromhtml for HTML
    """

    def __init__(self, content: str, content_type: str, lang: str = "en"):
        self.lang = lang
        self.secname_to_text: typing.Dict[str, str] = {}
        self.nodes: typing.Dict[
            int, typing.List[typing.Union[WikitextNode, HTMLNode]]
        ] = {}
        if content_type == "wikitext":
            self.wikitext_to_bagofnodes(content)
        elif content_type == "html":
            self.html_to_bagoftags(content)

    def wikitext_to_bagofnodes(self, wikitext: str):
        """Build bag of document nodes from Wikipedia article.
        Includes special Section nodes that will cover any changes made to that section.
        Excludes text, which will be processed later (and is captured by the Section nodes).
        """
        wt = mwhell.parse(wikitext, skip_style_tags=True)
        for sidx, s in enumerate(wt.get_sections(flat=True)):
            if s:
                sec_id = sec_to_name(s, sidx)
                s_node = WikitextNode(
                    sec_id, ntype="Section", mwnode=s, section=sec_id, lang=self.lang
                )
                self.secname_to_text[sec_id] = wikitext_to_plaintext(s, lang=self.lang)
                self.nodes[s_node.content_hash] = self.nodes.get(
                    s_node.content_hash, []
                ) + [s_node]

    def html_to_bagoftags(self, html: str):
        """Build bag of document nodes from Wikipedia article.
        Includes special Section nodes that will cover any changes made to that section.
        Excludes text, which will be processed later (and is captured by the Section nodes).
        """
        article = mwhtml.Article(html, flatten_sections=True)
        wiki = article.get_wiki()
        for sidx, s in enumerate(article.wikistew.get_sections()):
            sec_id = f"{sidx}"
            s_wikistew = mwhtml.WikiStew(s.html_tag, wiki=wiki)
            # remove attrs with relative values (i.e. unstable)
            strip_tag_to_core(s_wikistew.tag)
            s_node = HTMLNode(
                name=f"Section {sec_id}",
                ntype="Section",
                mwnode=s_wikistew,
                section=sec_id,
            )
            self.secname_to_text[sec_id] = "\n".join(
                [p for p in wikistew_to_plaintext(s_wikistew)]
            )
            self.nodes[s_node.content_hash] = self.nodes.get(
                s_node.content_hash, []
            ) + [s_node]

    def expand_nested(self):
        """Expand section nodes to include all references, links, etc. contained in them."""
        to_add = {}
        for n_hash in self.nodes:
            for n in self.nodes[n_hash]:
                for nn in n.unnest():
                    to_add[nn.content_hash] = to_add.get(nn.content_hash, []) + [nn]

        for n_hash in to_add:
            for n in to_add[n_hash]:
                self.nodes[n_hash] = self.nodes.get(n_hash, []) + [n]


class Differ:
    """
    Find structural differences between two WikitextBags
    """

    def __init__(self, t1: ContentBag, t2: ContentBag):
        self.t1 = t1
        self.t2 = t2
        self.diff()

    def diff(self):
        # first pass on high-level nodes to prune down to just the ones that changed
        self.sym_diff()
        # expand out changed nodes and re-diff
        self.t1.expand_nested()
        self.t2.expand_nested()
        self.sym_diff()

    def sym_diff(self):
        # prune down node dictionaries to just unmatched nodes
        t1n = self.t1.nodes
        t2n = self.t2.nodes
        t1n_hashes = list(t1n.keys())
        for n_hash in t1n_hashes:
            # if this doesn't match, can leave t1n dictionary unchanged because all nodes should be kept for this hash
            if n_hash in t2n:
                diff = len(t1n[n_hash]) - len(t2n[n_hash])
                # fully-matched: remove from both sides
                if diff == 0:
                    t1n.pop(n_hash)
                    t2n.pop(n_hash)
                # extras in t1: remove from t2 and keep just extras in t1
                elif diff > 0:
                    t2n.pop(n_hash)
                    t1n[n_hash] = t1n[n_hash][:diff]
                # extras in t2: remove from t1 and keep just extras in t2
                elif diff < 0:
                    t1n.pop(n_hash)
                    t2n[n_hash] = t2n[n_hash][: abs(diff)]
        # no need to loop through t2n because what's left in it doesn't have any matches

    def count_actions(self):
        """Explain differences."""
        edit_types = {}
        prev_text_sections = set()
        curr_text_sections = set()

        # aggregate by node type to identify non-matching nodes
        prev_ntypes = {}
        for n_hash in self.t1.nodes:
            for n in self.t1.nodes[n_hash]:
                prev_ntypes[n.ntype] = prev_ntypes.get(n.ntype, 0) + 1
                if n.ntype == "Section":
                    prev_text_sections.add(n.section)
        curr_ntypes = {}
        for n_hash in self.t2.nodes:
            for n in self.t2.nodes[n_hash]:
                curr_ntypes[n.ntype] = curr_ntypes.get(n.ntype, 0) + 1
                if n.ntype == "Section":
                    curr_text_sections.add(n.section)

        all_ntypes = set(prev_ntypes.keys()).union(set(curr_ntypes.keys()))
        for n_type in all_ntypes:
            # change is overlap in nodes; removal are extras in previous; insert are extras in current
            chg = min(prev_ntypes.get(n_type, 0), curr_ntypes.get(n_type, 0))
            rem = prev_ntypes.get(n_type, 0) - chg
            ins = curr_ntypes.get(n_type, 0) - chg
            edit_types[n_type] = {}
            if chg > 0:
                edit_types[n_type]["change"] = chg
            if rem > 0:
                edit_types[n_type]["remove"] = rem
            if ins > 0:
                edit_types[n_type]["insert"] = ins

        lang = self.t1.lang
        prev_text = "\n".join([self.t1.secname_to_text[s] for s in prev_text_sections])
        curr_text = "\n".join([self.t2.secname_to_text[s] for s in curr_text_sections])

        text_changes = parse_change_text(prev_text, curr_text, lang=lang)
        edit_types.update(text_changes)

        return edit_types
