"""Process Markdown document.

This module allows to manipulate Markdown document:
- create a navigable Markdown object from a text,
- add, remove child nodes to a Markdown object,
- extend a Markdown document with another Markdown document,
- provide utility methods to construct toc.
"""

import re
import unicodedata
from parser import Parser
from textwrap import dedent
from typing import List, Optional


class Node:
    """Represent the base class for a Markdown document token."""

    content: Optional[str]

    def __init__(self, content: Optional[str]) -> None:
        self.content = content


class Code(Node):
    """A code block token (https://daringfireball.net/projects/markdown/syntax#precode)."""

    pass


class Paragraph(Node):
    """A paragraph token (https://daringfireball.net/projects/markdown/syntax#p)."""

    pass


class Whitespace(Node):
    """A whitespace token."""

    pass


def build_header(title: str, level: int, _id: str | None) -> str:
    """Constructs a header in Markdown format.

    Arg:
        title: title of the header.
        level: 1 base index of the header level
    """
    hashes = "#" * level
    if _id:
        return f"{hashes} {title} {{#{_id}}}\n"
    else:
        return f"{hashes} {title}\n"


class Header(Node):
    """A header token (https://daringfireball.net/projects/markdown/syntax#header)."""

    title: str
    level: int
    _id: str | None

    def __init__(self, title: str, level: int, _id: str = None) -> None:
        super().__init__(content=None)
        self.title = title
        self.level = level
        self._id = _id
        self.update_content()

    def indent(self, count: int) -> None:
        """Indent or dedent a header

        Args:
            count: number of level to indent, can be negative to dedent.
        """
        self.level += count
        self.update_content()

    def update_content(self) -> None:
        self.content = build_header(title=self.title, level=self.level, _id=self._id)

    @property
    def id(self) -> str | None:
        return self._id

    @id.setter
    def id(self, value: str | None):
        self._id = value


class RefLink(Node):
    """A reference link token (https://daringfireball.net/projects/markdown/syntax#link)."""

    ref: str
    link: str

    def __init__(self, ref: str, link: str) -> None:
        super().__init__(content=None)
        self.ref = ref
        self.link = link
        self.update_content()

    def update_content(self) -> None:
        self.content = f"[{self.ref}]: {self.link}\n"


class Table(Node):
    """A table"""

    def reformat(self) -> None:
        """
        Format a Markdown table so all column are maximized.

        Example:

        ```markdown
        | a | b | c|
        |---|---|--|
        | aaaaa | bbbbb | cccc |
        | dd | ee | f |
        ```

        is reformatted to

        ```markdown
        | a     | b     | c    |
        |-------|-------|------|
        | aaaaa | bbbbb | cccc |
        | dd    | ee    | f    |
        ```
        """
        # Convert our content to an array of strings
        if not self.content:
            return
        lines = self.content.splitlines()
        rows = [line.split("|")[1:-1] for line in lines]
        rows_count = len(rows)
        cols_count = len(rows[0])
        cells = [
            [rows[r][c].strip() for c in range(cols_count)] for r in range(rows_count)
        ]
        max_lengths = [
            max([len(cells[r][c]) for r in range(rows_count)])
            for c in range(cols_count)
        ]
        cells_normalized = [["" for _ in range(cols_count)] for _ in range(rows_count)]
        for c in range(cols_count):
            max_length = max_lengths[c]
            for r in range(rows_count):
                cell = cells[r][c]
                if r == 1:
                    pad_char = "-"
                else:
                    pad_char = " "
                cell_normalized = cell.ljust(max_length, pad_char).center(
                    max_length + 2, pad_char
                )
                cells_normalized[r][c] = cell_normalized
        # Reconstruct content
        self.content = "".join(
            ["|" + "|".join(row) + "|\n" for row in cells_normalized]
        )


def parse_paragraph(parser: Parser) -> Paragraph:
    content = ""
    while parser.peek() != "":
        if parser.peek() == "\n":
            content += parser.read()
            line = parser.peek_while(lambda it: it != "\n")
            if is_blank(line):
                return Paragraph(content=content)
            continue
        content += parser.read()
    return Paragraph(content=content)


def is_blank(line: str) -> bool:
    """Return True if line is made of whitespace, False otherwise."""
    for c in line:
        if not is_whitespace(c):
            return False
    return True


def is_whitespace(c: str) -> bool:
    """Return True if c is a whitespace, False otherwise."""
    return c == " " or c == "\t" or c == "\n"


def parse_whitespace(parser: Parser) -> Whitespace:
    """Parse and return a whitespace token."""
    content = parser.read_while(is_whitespace)
    return Whitespace(content=content)


def parse_code(parser: Parser) -> Code:
    """Parse and return a code block token."""
    separator = parser.read(3)
    content = separator

    while parser.peek() != "":
        c = parser.peek(3)
        if c == separator:
            content += parser.read(3)
            return Code(content=content)
        content += parser.read()
    return Code(content=content)


def parse_table(parser: Parser) -> Table:
    """Parse and return a table token."""
    content = ""
    while parser.left() > 0:
        line = parser.read_while(lambda it: it != "\n")
        _ = parser.read()
        content += line + "\n"
        c = parser.peek()
        if c != "|":
            break
    return Table(content=content)


def parse_header(parser: Parser) -> Header:
    """Parse and return a header token."""
    hashes = parser.read_while(lambda it: it == "#")
    _ = parser.read_while(lambda it: is_whitespace(it))
    title = parser.read_while(lambda it: it != "\n")
    _ = parser.read()
    return Header(title=title, level=len(hashes))


def parse_ref_link(parser: Parser) -> RefLink:
    """Parse and return a reference link token."""
    line = parser.read_while(lambda it: it != "\n")
    _ = parser.read()
    ret = re.match(r"\[(?P<ref>.+)]:\s+(?P<link>.+)", line)
    assert ret is not None
    return RefLink(ref=ret.group("ref"), link=ret.group("link"))


def parse_markdown(text: str) -> "MarkdownDoc":
    """Parse a Markdown text and return a document instance."""
    processed_text = text
    parser = Parser(buffer=processed_text)

    root = MarkdownDoc()

    while parser.peek() != "":
        node: Node
        c = parser.peek()

        # Whitespace parsing:
        if is_whitespace(c):
            node = parse_whitespace(parser=parser)
            root.add_child(node)
            continue

        # Code parsing:
        if c == "-" or c == "~" or c == "`":
            sep = parser.peek(3)
            if sep == "---" or sep == "~~~" or sep == "```":
                node = parse_code(parser=parser)
                root.add_child(node)
                continue

        # Header parsing:
        if c == "#":
            node = parse_header(parser=parser)
            root.add_child(node)
            continue

        # Parse Reference-style Links
        if c == "[":
            line = parser.peek_while(lambda it: it != "\n")
            if re.match(r"\[.+]: .+", line):
                node = parse_ref_link(parser=parser)
                root.add_child(node)
                continue

        if c == "|":
            node = parse_table(parser=parser)
            root.add_child(node)
            continue

        # Default node parsing:
        node = parse_paragraph(parser=parser)
        root.add_child(node)

    return root


class MarkdownDoc:
    """A class used to represent Markdown document.

    Attributes:
        children: children nodes of this document.
    """

    children: List[Node]

    def __init__(self) -> None:
        self.children = []

    def add_child(self, node) -> None:
        """Add a node to the document."""
        self.children.append(node)

    def find_first(self, func, start: Optional[Node] = None) -> Optional[Node]:
        """Search the first child node that meet a criteria.

        Args:
            func: a callable predicate to filter against.
            start: a node to start the search from (it can be the returned result).
        """
        if start:
            start_index = self.children.index(start)
        else:
            start_index = 0
        for child in self.children[start_index:]:
            if func(child):
                return child
        return None

    def to_text(self) -> str:
        """Return the text representation of this document."""
        ref_links_nodes = [c for c in self.children if isinstance(c, RefLink)]
        other_nodes = [c for c in self.children if not isinstance(c, RefLink)]
        nodes = [*other_nodes, *ref_links_nodes]
        return "".join([node.content for node in nodes if node.content])

    def indent(self, count: int = 1) -> None:
        """Indent all headers of a specified count level."""
        for c in self.children:
            if isinstance(c, Header):
                c.indent(count=count)

    def extend(self, other: "MarkdownDoc") -> None:
        """Extend the current document with another Markdown document instance."""
        self.children.extend(other.children)

    def insert_node(self, start: Node, node: Node) -> None:
        """Insert a child node to the current document, before a specified node."""
        index = self.children.index(start)
        self.children.insert(index, node)

    def insert_nodes(self, start: Node, nodes: List[Node]) -> None:
        """Insert children nodes to the current document, before a specified node."""
        index = self.children.index(start)
        self.children[index:index] = nodes

    def remove_node(self, node: Node) -> None:
        """Remove a child node."""
        try:
            index = self.children.index(node)
            self.children.pop(index)
        except ValueError:
            pass

    def remove_nodes(self, nodes: List[Node]) -> None:
        """Remove children nodes."""
        self.children = [node for node in self.children if node not in nodes]

    def slice(self, node_a: Node, node_b: Node) -> List[Node]:
        """Return a slice of the current children nodes

        Args:
            node_a: lower node (included in the returned slice)
            node_b: upper node (excluded from the returned slice)
        """
        index_a = self.children.index(node_a)
        index_b = self.children.index(node_b)
        return self.children[index_a:index_b]

    def next_node(self, node: Node) -> Optional[Node]:
        """Return the following node of a specified child node."""
        index = self.children.index(node)
        if index < len(self.children):
            return self.children[index + 1]
        else:
            return None

    def previous_node(self, node: Node) -> Optional[Node]:
        """Return the following node of a specified child node."""
        index = self.children.index(node)
        if index > 0:
            return self.children[index - 1]
        else:
            return None

    def toc(self) -> str:
        """Return a table-of-content of the current document."""

        def slugify(value: str) -> str:
            value = (
                unicodedata.normalize("NFKD", value)
                .encode("ascii", "ignore")
                .decode("ascii")
            )
            value = re.sub(r"[^\w\s/-]", "", value).strip().lower()
            return re.sub(r"[-\s]+", "-", value).replace("/", "")

        headers = [child for child in self.children if isinstance(child, Header)]

        # Find the minimum header level, we'll delta all documents level from this
        min_level = min([h.level for h in headers])

        toc = dedent(
            """\
        # Table of Contents
        
        """
        )
        for header in headers:
            indent = "    " * (header.level - min_level)
            if header.id:
                slug = header.id
            else:
                slug = slugify(header.title)
            line = f"{indent}* [{header.title}](#{slug})\n"
            toc += line
        toc += "\n"
        return toc
