import typing
from collections import namedtuple
from copy import copy
from heapq import heappop, heappush

import mwparserfromhell as mwhell
import mwparserfromhtml as mwhtml
from anytree import NodeMixin, PostOrderIter

from mwedittypes.utils import (
    find_nested_media,
    find_nested_textformatting,
    node_to_name,
    sec_to_name,
    simple_node_class,
    standardize_citations,
    strip_tag_to_core,
    wikistew_to_plaintext,
    wikitext_to_plaintext,
)

State = namedtuple(
    "State", ["full_cost", "current_cost", "heap_idx", "t1_idx", "t2_idx", "path"]
)


# 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 tree diff between two revisions."""
    # To provide proper structure, need all content to be nested under a section
    prev_tree = ContentTree(content=prev_content, content_type=content_type, lang=lang)
    curr_tree = ContentTree(content=curr_content, content_type=content_type, lang=lang)
    d = Differ(prev_tree, curr_tree)
    diff = d.convert_path_to_diff()
    result = diff.post_process(prev_tree.secname_to_text, curr_tree.secname_to_text)
    return result


class WikitextOrderedNode(NodeMixin):
    """
    Extension of anytree library node to support tree differ.
    """

    def __init__(
        self,
        name,
        ntype="Text",
        idx=-1,
        mwnode=None,
        section=None,
        parent=None,
        children=None,
        lang="en",
    ):
        super(WikitextOrderedNode, self).__init__()
        self.name = name  # For debugging purposes
        self.ntype = ntype  # Different node types can be treated differently when computing equality
        if self.ntype in {"Text", "Text Formatting", "List"}:
            self.can_move = False
        else:
            self.can_move = True
        self.mwnode = mwnode
        self.text = (
            str(mwnode) if mwnode is not None else ""
        )  # Text that can then be passed to a diffing library
        # Used for quickly computing equality for most nodes.
        # Generally this just a simple hash of self.mwnode (wikitext associated with a node) but
        # the text hash for sections is based on all the content within the section
        # so it can be used for pruning while self.text is just the text that creates the section
        # e.g., "==Section==\nThis is a section." would have as text "==Section==" but hash the full.
        # so the Differ doesn't identify a section as changing when content within it is changed
        self.content_hash = hash(self.text)
        self.idx = idx  # Used by Differ -- Post order on tree from 0...# nodes - 1
        self.section = section  # section that the node is a part of -- useful for formatting final diff
        self.parent = parent
        # `children` is an attribute or OrderedNode class that's updated as connections are made.
        # We provide an override here so that we can trim the tree when a section hasn't changed.
        if children:
            self.children = children
        self.leftmostidx = None
        self.lang = lang

    def leftmost(self):
        if self.leftmostidx is None:
            self.leftmostidx = self.idx if self.is_leaf else self.children[0].leftmost()
        return self.leftmostidx

    def unnest(self):
        """Build tree of document nodes by recursing within a single section.

        This approach starts with a Section node. It goes through the direct children (recursive=False)
        and creates an OrderedNode for each. It then loops through that node's full descendant list
        (recursive=True) and attaches them appropriately. Ideally we'd want a recursive function that
        unnests level-by-level because that'd be more flexible and automatically track the child-parent
        relationship. But this flattened, two-stage approach is necessary for a few reasons:
        * We want first-order Text nodes but don't want deeper Text nodes, which are usually just e.g.,
        the names of Tags like "ref" for <ref>. This could be solved by checking the parent type.
        * The contents of Gallery tags are treated as text even if they have e.g., nested WikiLinks so they
        need special re-parsing to be handled appropriately. This likely could be solved too.
        * 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 the output tree to look like:
        <--rest-of-tree-- Reference <--child-of-- Template (cite web) <--child-of-- WikiLink (Gallery)
                                                                ^--------child-of-- External Link (http://digital...)
        """
        # make sure working with a Section node, which is natively a WikiCode object
        if isinstance(self.mwnode, mwhell.wikicode.Wikicode):
            for n in self.mwnode.ifilter(recursive=False):
                node = WikitextOrderedNode(
                    node_to_name(n, self.lang),
                    ntype=simple_node_class(n, self.lang),
                    mwnode=n,
                    section=self.section,
                    parent=self,
                    lang=self.lang,
                )
                if node.ntype == "Gallery":
                    # strip leading / trailing gallery tags so parser correctly parses everything in between
                    # otherwise links, templates, etc. is treated as text
                    gallery_start = node.text.find(">")
                    gallery_end = node.text.rfind("<")
                    try:
                        # the break is a hack; it'll be skipped in the ifilter loop; otherwise first image skipped
                        wt = mwhell.parse(
                            "<br>" + node.text[gallery_start + 1 : gallery_end],
                            skip_style_tags=True,
                        )
                    except Exception:  # fallback
                        wt = mwhell.parse(node.mwnode)
                # mwhell.parse on parsed node automatically carries over skip_style_tags and doesn't re-parse
                else:
                    wt = mwhell.parse(node.mwnode)
                parent_ranges = [
                    (0, len(node.text), node)
                ]  # (start idx of node, end idx of node, node object)
                for idx, nn in enumerate(wt.ifilter(recursive=True)):
                    if idx == 0:
                        continue  # skip root node -- already set or placeholder <br> node for galleries
                    ntype = simple_node_class(nn, self.lang)
                    if ntype == "Text":
                        # media w/o bracket will be IDed as text by mwparserfromhell
                        # and templates / galleries are where we find this nested media.
                        # any discovered media will just be the start and aren't
                        # guaranteed to be the whole image+caption etc.
                        if node.ntype == "Template" or node.ntype == "Gallery":
                            for m in find_nested_media(
                                str(nn), is_gallery=(node.ntype == "Gallery")
                            ):
                                nn_node = WikitextOrderedNode(
                                    f"Media: {m[:10]}...",
                                    ntype="Media",
                                    mwnode=m,
                                    section=node.section,
                                    parent=node,
                                    lang=self.lang,
                                )
                                offset = node.text.find(str(m), parent_ranges[0][0])
                                parent_ranges.insert(
                                    0, (offset, offset + len(m), nn_node)
                                )
                    # 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 changes and text changes
                    # this leads to much faster parsing in exchange for not knowing how many table cells were edited
                    elif ntype == "Table Element":
                        pass
                    else:
                        # start looking from the start of the latest node
                        node_start = node.text.find(str(nn), parent_ranges[0][0])
                        # identify direct parent of node
                        for parent_start, parent_end, parent_node in parent_ranges:
                            if (
                                node_start < parent_end
                            ):  # starts before end of a previous node; already know it begins after it
                                nn_node = WikitextOrderedNode(
                                    node_to_name(nn, lang=self.lang),
                                    ntype=ntype,
                                    mwnode=nn,
                                    section=node.section,
                                    parent=parent_node,
                                    lang=self.lang,
                                )
                                parent_ranges.insert(
                                    0, (node_start, node_start + len(nn), nn_node)
                                )
                                break
                if "''" in node.text:
                    for tfnode, tfspan in find_nested_textformatting(node.text):
                        for parent_start, parent_end, parent_node in parent_ranges:
                            if tfspan[0] >= parent_start and tfspan[1] <= parent_end:
                                nn_node = WikitextOrderedNode(
                                    f"Text-Formatting: {tfnode}",
                                    ntype="Text Formatting",
                                    mwnode=tfnode,
                                    section=node.section,
                                    parent=parent_node,
                                    lang=self.lang,
                                )
                                break

    def dump(self):
        return {"type": self.ntype, "text": self.text, "section": self.section}


class HTMLOrderedNode(NodeMixin):
    """
    Extension of anytree library node to support tree differ.
    """

    def __init__(
        self,
        name,
        ntype="Text",
        idx=-1,
        mwnode=None,
        section=None,
        parent=None,
        children=None,
    ):
        super(HTMLOrderedNode, self).__init__()
        self.name = name  # For debugging purposes
        self.ntype = ntype  # Different node types can be treated differently when computing equality
        if self.ntype == "Text" or self.ntype.startswith("TF-"):
            self.can_move = False
        else:
            self.can_move = True
        self.mwnode = mwnode
        # Used for quickly computing equality for most nodes.
        # Strip irrelevant attrs from it so no false negatives when matching.
        # We have to do this here rather than once upfront because a few of these
        # attributes are actually necessary for identifying what type of element
        # a given node may be -- e.g., its class for infoboxes.
        stripped_node = copy(mwnode)
        strip_tag_to_core(stripped_node)
        self.content_hash = hash(stripped_node)
        self.idx = idx  # Used by Differ -- Post order on tree from 0...# nodes - 1
        self.section = section  # section that the node is a part of -- useful for formatting final diff
        self.parent = parent
        # `children` is an attribute or OrderedNode class that's updated as connections are made.
        # We provide an override here so that we can trim the tree when a section hasn't changed.
        if children:
            self.children = children
        self.leftmostidx = None

    def leftmost(self):
        if self.leftmostidx is None:
            self.leftmostidx = self.idx if self.is_leaf else self.children[0].leftmost()
        return self.leftmostidx

    def unnest(self):
        """Build tree of document nodes by recursing within a single HTML node.

        This approach starts with a single HTML node -- e.g., a single section with nested content
        and splits it into its component pieces to then identify what has changed between revisions.

        We have to handle that we might have to go down a few HTML elements -- e.g., a div or span --
        until we encounter a node that's recognizable as a Wiki Element -- e.g., wikilink or infobox.
        When we do encounter an element, that becomes the new parent so each found element is linked
        just to its direct parent wiki-element node.
        """
        children = self.mwnode.contents.copy()
        while children:
            tag = children.pop(0)
            # we're skipping text tags -- they'll be captured by section nodes
            # and handled later when dealing with text changes.
            if hasattr(tag, "attrs"):
                tag_type = mwhtml.parse.plaintext._tag_to_element(tag)
                # found element -- form node and unnest it from there
                # Exceptions: Navigation boxes are massive boxes full of
                # wikilinks that are almost exclusively inserted via templates
                # and so can be skipped. Otherwise, they incur a heavy cost.
                if tag_type and tag_type != "Navigation":
                    tag_start = tag.text.replace("\n", "\\n")[:10]
                    nested_node = HTMLOrderedNode(
                        name=f"{tag_type} ({tag_start})",
                        ntype=tag_type,
                        mwnode=tag,
                        section=self.section,
                        parent=self,
                    )
                    nested_node.unnest()
                # tag but not wiki element -- keep looping through its descendants
                # until we reach bottom or an element
                else:
                    children.extend(tag.contents.copy())
            # if text (not tag) and direct child of a section, keep top-level text
            # Otherwise too much for diffing -- e.g., a Citation [1] superscript generates
            # three more Text nodes: "[", "1", and "]" if we don't enforce section parent node.
            elif self.ntype == "Section":
                tag_type = "Text"
                text = tag.text
                if text:
                    tag_start = tag.text.replace("\n", "\\n")[:10]
                    nested_node = HTMLOrderedNode(
                        name=f"{tag_type} ({tag_start})",
                        ntype=tag_type,
                        mwnode=text,
                        section=self.section,
                        parent=self,
                    )

    def dump(self):
        return {"type": self.ntype, "text": self.mwnode, "section": self.section}


class ContentTree:
    """Tree structure for wikitext or HTML content."""

    def __init__(self, content: str, content_type: str, lang: str = "en"):
        self.lang = lang
        self.secname_to_text: typing.Dict[str, str] = {}
        if content_type == "wikitext":
            self.root = WikitextOrderedNode("root", ntype="Article", lang=self.lang)
            if content:
                self.wikitext_to_tree(content, lang)
        elif content_type == "html":
            if content:
                self.html_to_tree(content)
            else:
                self.root = HTMLOrderedNode(name="root", ntype="Article")

    def wikitext_to_tree(self, wikitext, lang):
        """Build tree of section nodes from Wikipedia wikitext.

        This approach builds a tree with an empty 'root' node on the 1st level and
        all of the article sections nested flatly underneath (including the Lead section).
        We don't unnest the content within a section until later when we've identified that
        the section is relevant to the diff.
        """
        wt = mwhell.parse(wikitext, skip_style_tags=True)
        for sidx, s in enumerate(wt.get_sections(flat=True)):
            sec_id = sec_to_name(s, sidx)
            self.secname_to_text[sec_id] = wikitext_to_plaintext(s, lang=lang)
            _ = WikitextOrderedNode(
                sec_id,
                ntype="Section",
                mwnode=s,
                section=sec_id,
                parent=self.root,
                lang=self.lang,
            )

    def html_to_tree(self, html: str):
        """Build tree of document nodes from Wikipedia HTML.

        This approach builds a tree with a 'root' node on the 1st level with the title and
        all of the article sections nested flatly underneath (including the Lead section).
        We don't unnest the content within a section until later when we've identified that
        the section is relevant to the diff.
        """
        article = mwhtml.Article(html, flatten_sections=True)
        wiki = article.get_wiki()
        self.root = HTMLOrderedNode(
            name="root",
            ntype="Article",
            mwnode=article.wikistew.tag.title,
        )
        standardize_citations(article.wikistew)
        for sidx, s in enumerate(article.wikistew.get_sections()):
            sec_id = str(sidx)
            s_wikistew = mwhtml.WikiStew(s.html_tag, wiki=wiki)
            self.secname_to_text[sec_id] = "\n".join(
                [p for p in wikistew_to_plaintext(s_wikistew)]
            )
            _ = HTMLOrderedNode(
                sec_id,
                ntype="Section",
                mwnode=s_wikistew.tag,
                section=sec_id,
                parent=self.root,
            )


class Differ:
    """Find structural differences between two Content Trees."""

    def __init__(
        self,
        t1: ContentTree,
        t2: ContentTree,
    ):
        self.align_trees(t1, t2)
        self.t1 = []
        self.t2 = []
        for i, n in enumerate(PostOrderIter(t1.root)):
            n.idx = i
            self.t1.append(n)
        for i, n in enumerate(PostOrderIter(t2.root)):
            n.idx = i
            self.t2.append(n)
        self.ins_cost = 1
        self.rem_cost = 1
        self.chg_cost = 1
        # pre-compute lowest-idx match for every node (if it exists)
        # this is used for getting a better estimate for the remaining
        # cost for the A* heuristic.
        self.i_matches_j = {}
        for n_i in self.t1:
            for n_j in self.t2:
                if n_i.can_move and n_j.can_move:
                    if n_i.content_hash == n_j.content_hash:
                        self.i_matches_j[n_i.idx] = n_j.idx
                        break

        self.path = self.populate_translations(t1.root, t2.root)

    def align_trees(self, t1, t2):
        """Quick heuristic preprocessing to reduce tree differ time.

        Components:
        * If a section matches exactly across trees, only retain it for structure but not children.
        * If a section does not match, make sure fully expanded to capture all node changes.
        """
        t1_sections = [n for n in PostOrderIter(t1.root) if n.ntype == "Section"]
        t2_sections = [n for n in PostOrderIter(t2.root) if n.ntype == "Section"]
        for secnode1 in t1_sections:
            found = False
            for sn2_idx in range(len(t2_sections)):
                secnode2 = t2_sections[sn2_idx]
                if secnode1.content_hash == secnode2.content_hash:
                    t2_sections.pop(sn2_idx)  # only match once
                    found = True
                    break
            # no match - unnest section into full tree for diffing. Second check
            # shouldn't be necessary but is extra security over double-unnesting
            if not found and not secnode1.children:
                secnode1.unnest()

        # never matched - unnest section into full tree for diffing
        for secnode2 in t2_sections:
            if not secnode2.children:
                secnode2.unnest()

    def heuristic(self, t1_idx, t2_idx):
        """Estimate remaining cost from (i, j) to (None, None).

        Right now, this is highly simplistic with the full cost estimate
        defaulting to current cost of a path (+0). In practice, this seems
        to work quite well -- i.e. best performing on test cases and just
        as efficient as slightly more complicated approaches (see below).

        An alternative proposal is listed below that should be more efficient
        because it tries to estimate how much more cost is also remaining
        but triggers incorrect results in `test_insert_category` so clearly
        is not actually more optimal. Maybe because it would nudge the
        algorithm towards solution that are heavy on "changes" as they move
        the path most quickly towards a solution even if they're unnecessary
        and a key insert or remove would avoid the need for other nodes being
        changed. Replacing `self.chg_cost` below with `0.5` actually brings us
        back to the optimal solution but I don't understand why so am opting
        for simplicity until I better understand.

        ```
        if t1_idx is None and t2_idx is None:
            return 0
        remaining_in_t1 = t1_idx + 1 if t1_idx is not None else 0
        remaining_in_t2 = t2_idx + 1 if t2_idx is not None else 0
        possible_change = min(remaining_in_t1, remaining_in_t2)
        remove_est = (remaining_in_t1 - possible_change) * self.rem_cost
        insert_est = (remaining_in_t2 - possible_change) * self.ins_cost
        change_est = possible_change * self.chg_cost
        return remove_est + insert_est + change_est
        ```
        """
        # made it!
        if t1_idx is None and t2_idx is None:
            return 0
        # one tree exhausted -- only thing left to do is insert or remove the remaining tree
        elif t1_idx is None or t2_idx is None:
            remaining_in_t1 = t1_idx + 1 if t1_idx is not None else 0
            remaining_in_t2 = t2_idx + 1 if t2_idx is not None else 0
            return (remaining_in_t1 * self.rem_cost) + (remaining_in_t2 * self.ins_cost)
        # skip potential matches between two trees (no cost edits)
        # assume as many changes as possible. rest are inserts or deletes.
        # possibly could also estimate potential changes, either heuristic:
        # possible_change = min(remaining_in_t1, remaining_in_t2)
        # or better, having a self.i_matches_j but that gives lowest idx
        # for node of the same type that we check if a match isn't found.
        # and then change_est = changes_remaining * self.chg_cost
        # and then subtract out changes_remaining from remaining_in_t1 and _t2
        else:
            matching_remaining = 0
            for idx in range(t1_idx + 1):
                if idx in self.i_matches_j and self.i_matches_j[idx] <= t2_idx:
                    matching_remaining += 1
            remaining_in_t1 = t1_idx + 1 - matching_remaining
            remaining_in_t2 = t2_idx + 1 - matching_remaining
            remove_est = remaining_in_t1 * self.rem_cost
            insert_est = remaining_in_t2 * self.ins_cost
            return remove_est + insert_est

    def populate_translations(self, root1, root2):
        """Find lowest-cost set of transactions to get between two trees.

        This uses an A* approach where we constantly explore the most "promising" route
        at each turn of the algorithm. This requires a heuristic to estimate how much
        cost remains for a given path between the two trees. A previous approach used
        dynamic programming to efficiently compute all possible routes and choose the
        cheapest, but this still was too slow / expensive for larger changes.
        """
        # the heap findest the "smallest" item to return when popping. It starts with the 0-index
        # attribute. If states have equal estimated costs, it will start checking the 1-index, etc.
        # We use the heap_idx variable to make sure that the heap doesn't check the path attribute,
        # which contains None values that will throw errors when comparing with integers.
        heap_idx = 0
        start_state = State(
            full_cost=0 + self.heuristic(root1.idx, root2.idx),
            current_cost=0,
            heap_idx=heap_idx,
            t1_idx=root1.idx,
            t2_idx=root2.idx,
            path=[],
        )
        heap_idx += 1
        queue = []
        visited = set()
        heappush(queue, start_state)

        while queue:
            nextitem = heappop(queue)
            curr_cost = nextitem.current_cost
            i = nextitem.t1_idx
            j = nextitem.t2_idx
            path = nextitem.path

            if (i, j) in visited:
                continue
            visited.add((i, j))

            # reached destination - return path
            if i is None and j is None:
                return path

            # Add possible actions to queue: insert, remove, change
            i_minus_1 = i - 1 if i else None
            j_minus_1 = j - 1 if j else None

            # try removing node from t1
            if i is not None:
                new_cost = curr_cost + self.rem_cost
                new_path = path + [(i, None)]
                heappush(
                    queue,
                    State(
                        new_cost + self.heuristic(i_minus_1, j),
                        new_cost,
                        heap_idx,
                        i_minus_1,
                        j,
                        new_path,
                    ),
                )
                heap_idx += 1

            # try inserting node into t2
            if j is not None:
                new_cost = curr_cost + self.ins_cost
                new_path = path + [(None, j)]
                heappush(
                    queue,
                    State(
                        new_cost + self.heuristic(i, j_minus_1),
                        new_cost,
                        heap_idx,
                        i,
                        j_minus_1,
                        new_path,
                    ),
                )
                heap_idx += 1

            # try changing t1 node into t2 node
            if i is not None and j is not None:
                n1 = self.t1[i]
                n2 = self.t2[j]

                # if different types of nodes, we don't consider any changes
                if n1.ntype == n2.ntype:
                    # exact match - no cost "change"
                    if n1.content_hash == n2.content_hash:
                        new_cost = curr_cost
                        new_path = path.copy()
                    # same node type but different contents - change cost
                    else:
                        new_cost = curr_cost + self.chg_cost
                        new_path = path + [(i, j)]

                    heappush(
                        queue,
                        State(
                            new_cost + self.heuristic(i_minus_1, j_minus_1),
                            new_cost,
                            heap_idx,
                            i_minus_1,
                            j_minus_1,
                            new_path,
                        ),
                    )
                    heap_idx += 1

        # No path found (hopefully shouldn't happen)
        return None

    def convert_path_to_diff(self):
        """Explain transactions."""
        if self.path:
            remove = []
            insert = []
            change = []
            for i in range(0, len(self.path)):
                if self.path[i][0] is None:
                    ins_node = self.t2[self.path[i][1]]
                    insert.append(ins_node)
                elif self.path[i][1] is None:
                    rem_node = self.t1[self.path[i][0]]
                    remove.append(rem_node)
                else:
                    prev_node = self.t1[self.path[i][0]]
                    curr_node = self.t2[self.path[i][1]]
                    change.append((prev_node, curr_node))
            diff = {"remove": remove, "insert": insert, "change": change}
            self.detect_moves(diff)
            return Diff(
                nodes_removed=diff["remove"],
                nodes_inserted=diff["insert"],
                nodes_changed=diff["change"],
                nodes_moved=diff["move"],
            )
        else:
            return Diff(
                nodes_removed=[], nodes_inserted=[], nodes_changed=[], nodes_moved=[]
            )

    def detect_moves(self, diff):
        """Detect when nodes were moved (as opposed to removed/inserted/changed) and update diff.

        Easy case:
        * If a removed node from the previous content matches an inserted node from the current content,
          then remove those nodes from the corresponding remove/insert spots and combine into a "move". Examples:
          Input: Remove(A) and Insert(A) -> Move(A,A)

        Harder cases:
        * If a changed node from the previous content matches a changed/inserted node from the current content,
          then match them as "move" like above but also update the corresponding "change" node in the current
          content to be an insert. Examples:
          Input: Change(A,B) and Insert(A) -> Move(A,A) and Insert(B)
          Input: Change(A,B) and Remove(B) -> Remove(A) and Move(B,B)
          Input: Change(A,B) and Change(C,A) -> Move(A,A) and Insert(B) and Remove(C)
          Input: Change(A,B) and Change(B,A) -> Move(A,A) and Move(B,B)
        """

        # build list of all prev and curr nodes to compare for matches
        prev_nodes = [
            ("remove", i, pn) for i, pn in enumerate(diff["remove"]) if pn.can_move
        ]
        curr_nodes = [
            ("insert", j, cn) for j, cn in enumerate(diff["insert"]) if cn.can_move
        ]
        for k in range(len(diff["change"])):
            if diff["change"][k][0].can_move:
                prev_nodes.append(("change", k, diff["change"][k][0]))
                curr_nodes.append(("change", k, diff["change"][k][1]))

        # loop through prev/curr nodes and look for matches. constraints:
        # * nodes can only match with one other node
        # * if a node is part of a change, make sure it's corresponding node is moved to insert/remove accordingly
        prev_moved = []
        curr_moved = []
        curr_found = set()
        change_to_insert = {}
        change_to_remove = {}
        for pet, pidx, pn in prev_nodes:
            for cet, cidx, cn in curr_nodes:
                cid = f"{cet}-{cidx}"
                # same type/text and not already part of a move
                if (
                    pn.ntype == cn.ntype
                    and pn.content_hash == cn.content_hash
                    and cid not in curr_found
                ):
                    prev_moved.append((pet, pidx))
                    curr_moved.append((cet, cidx))
                    curr_found.add(cid)
                    if pet == "change":
                        corresponding_changed_node = diff["change"][pidx][1]
                        change_to_insert[pidx] = corresponding_changed_node
                    if cet == "change":
                        corresponding_changed_node = diff["change"][cidx][0]
                        change_to_remove[cidx] = corresponding_changed_node
                    break

        # populate move list
        # if from a change, make sure it also isn't set to be moved to insert/remove
        diff["move"] = []
        if prev_moved:
            for i in range(len(prev_moved)):
                pet, pidx = prev_moved[i]
                cet, cidx = curr_moved[i]
                pn = diff[pet][pidx]
                if (
                    pet == "change"
                ):  # pn is not the node but tuple of (prev_node, curr_node)
                    pn = pn[0]
                    if (
                        pidx in change_to_remove
                    ):  # don't add to remove -- was involved in its own move
                        change_to_remove.pop(pidx)
                cn = diff[cet][cidx]
                if cet == "change":
                    cn = cn[1]
                    if cidx in change_to_insert:
                        change_to_insert.pop(
                            cidx
                        )  # don't add to insert -- was involved in its own move
                diff["move"].append((pn, cn))
            moved = sorted(set(prev_moved + curr_moved), reverse=True)
            for et, idx in moved:
                diff[et].pop(idx)

            diff["insert"].extend(list(change_to_insert.values()))
            diff["remove"].extend(list(change_to_remove.values()))


class Diff:
    """
    Diff result with helper functions for post-processing / cleaning up the result
    """

    def __init__(self, nodes_removed, nodes_inserted, nodes_changed, nodes_moved):
        self.remove = [n.dump() for n in nodes_removed]
        self.insert = [n.dump() for n in nodes_inserted]
        self.change = [
            {"prev": pn.dump(), "curr": cn.dump()} for pn, cn in nodes_changed
        ]
        self.move = [{"prev": pn.dump(), "curr": cn.dump()} for pn, cn in nodes_moved]
        self.sections_p_to_c = {}
        self.sections_c_to_p = {}
        self.processed = False

    def post_process(self, sections_prev, sections_curr):
        if not self.processed:
            self._section_mapping(sections_prev, sections_curr)
            self._merge_text_changes(sections_prev, sections_curr)
            self._align_section_names()
            self.result = {
                "remove": self.remove,
                "insert": self.insert,
                "change": self.change,
                "move": self.move,
            }
            self.processed = True
        return self.result

    def _align_section_names(self):
        """Align section names.

        For removals: nothing needed as they use previous section names.
        For inserts/changes/moves: switch to original section name as needed.
        """
        for n in self.insert:
            sec_name = n["section"]
            # update name to section in previous revision for consistency (if it exists)
            sec_id = self.sections_c_to_p[sec_name] or sec_name
            n["section"] = sec_id
        for n in self.change + self.move:
            cn = n["curr"]
            csec_name = cn["section"]
            # update name to section in previous revision for consistency (if it exists)
            csec_id = self.sections_c_to_p[csec_name] or csec_name
            cn["section"] = csec_id

    def _section_mapping(self, sections_prev, sections_curr):
        """Build mapping of sections between previous and current versions of article."""
        prev = list(sections_prev.keys())
        curr = list(sections_curr.keys())
        p_to_c = {}
        c_to_p = {}
        removed = []
        # removed sections map to null in current; inserted sections map to null in previous
        for n in self.remove:
            if n["type"] == "Section":
                for i, s in enumerate(prev):
                    if s == n["section"]:
                        removed.append(i)
                        break
        for idx in sorted(removed, reverse=True):
            p_to_c[prev[idx]] = None
            prev.pop(idx)

        inserted = []
        for n in self.insert:
            if n["type"] == "Section":
                for i, s in enumerate(curr):
                    if s == n["section"]:
                        inserted.append(i)
                        break
        for idx in sorted(inserted, reverse=True):
            c_to_p[curr[idx]] = None
            curr.pop(idx)

        # changes happen in place so don't effect structure of doc and can be ignored
        # for moved sections, reorder mapping so they are aligned again for dumping
        for c in self.move:
            pn = c["prev"]
            cn = c["curr"]
            if pn["type"] == "Section":
                prev_idx = None
                curr_idx = None
                for i, s in enumerate(prev):
                    if s == pn["section"]:
                        prev_idx = i
                        break
                for i, s in enumerate(curr):
                    if s == cn["section"]:
                        curr_idx = i
                        break
                if prev_idx is not None and curr_idx is not None:
                    s = curr.pop(curr_idx)
                    curr.insert(prev_idx, s)

        for i in range(len(prev)):
            p_to_c[prev[i]] = curr[i]
            c_to_p[curr[i]] = prev[i]

        self.sections_p_to_c = p_to_c
        self.sections_c_to_p = c_to_p

    def _merge_text_changes(self, sections_prev, sections_curr):
        """Replace isolated text changes with section-level text changes."""
        changes = []
        # check each previous section and add if not a match with its corresponding current section
        for psec in self.sections_p_to_c:
            csec = self.sections_p_to_c[psec]
            if csec is None:
                prev_text = sections_prev[psec]
                changes.append(
                    {
                        "prev": {
                            "type": "Text",
                            "text": prev_text,
                            "section": psec,
                        }
                    }
                )
            elif sections_prev[psec] != sections_curr[csec]:
                prev_text = sections_prev[psec]
                curr_text = sections_curr[csec]
                if prev_text != curr_text:
                    changes.append(
                        {
                            "prev": {
                                "type": "Text",
                                "text": prev_text,
                                "section": psec,
                            },
                            "curr": {
                                "type": "Text",
                                "text": curr_text,
                                "section": csec,
                            },
                        }
                    )
        # add in unmatched sections from current (new sections)
        for csec in self.sections_c_to_p:
            psec = self.sections_c_to_p[csec]
            if psec is None:
                curr_text = sections_curr[csec]
                changes.append(
                    {
                        "curr": {
                            "type": "Text",
                            "text": curr_text,
                            "section": csec,
                        }
                    }
                )

        # remove individual text changes
        for idx in range(len(self.remove) - 1, -1, -1):
            if self.remove[idx]["type"] == "Text":
                self.remove.pop(idx)
        for idx in range(len(self.insert) - 1, -1, -1):
            if self.insert[idx]["type"] == "Text":
                self.insert.pop(idx)
        for idx in range(len(self.change) - 1, -1, -1):
            if self.change[idx]["prev"]["type"] == "Text":
                self.change.pop(idx)

        # add in section-level text changes
        for c in changes:
            if "prev" in c and "curr" in c:
                self.change.append(c)
            elif "prev" in c:
                self.remove.append(c["prev"])
            elif "curr" in c:
                self.insert.append(c["curr"])
