"""
Parsing LaTeX-commands perfectly is difficult, because the rules are quite complex.
There are many attempts using regular expressions, but I haven't seen one that
could not be tricked.
The following code can still be tricked by implicit parameters (no brackets {}),
but otherwise should be safe.
"""

import logging
import typing

_logger = logging.getLogger(__file__)


class _ParserError(ValueError):
    def __init__(self, msg, position):
        super().__init__(msg)
        self.position = position


class LatexStream:
    """
    The LatexStreams job is to keep track of the reading position and
    whether we are currently in a comment section or the current symbol
    is escaped.
    """

    def __init__(self, text: str, pos: int = 0):
        self._text = text
        self._pos = pos
        self.is_escaped = False
        self.in_comment = False
        self._read_escape = False

    def next(self) -> str:
        """
        Return the next character and move the cursor the next position.
        Update the current status regarding comment or escaping.
        :return: Next character.
        """
        if not self.has_next():
            msg = "No next character."
            raise _ParserError(msg, self._pos)
        c = self._text[self._pos]
        self._pos += 1
        if self._read_escape:
            self._read_escape = False
            self.is_escaped = True
        else:
            self.is_escaped = False
        if not self.is_escaped and c == "%":
            self.in_comment = True
        if c == "\n":
            self.in_comment = False
        if not self.in_comment and not self.is_escaped and c == "\\":
            assert self._read_escape is False
            self._read_escape = True
        else:
            self._read_escape = False
        return c

    def advance(self, n: int = 1) -> None:
        """
        Advance the cursor by n characters.
        :param n: Number of characters to advance.
        :return: None
        """
        for _ in range(n):
            try:
                self.next()
            except _ParserError:
                break

    def peek(self, pure=False) -> typing.Optional[str]:
        """
        Return the current character. Do not move the cursor.
        :param pure: Only return characters that are not in a comment or escaped.
        :return: Next character.
        """
        if pure and (self._read_escape or self.in_comment):
            return None
        if not self.has_next():
            return None
        c = self._text[self._pos]
        return c

    def has_next(self) -> bool:
        """
        Returns whether there is a next character.
        :return: True if there is a next character.
        """
        return self._pos < len(self._text)

    def pos(self) -> int:
        """
        Current position of the cursor.
        :return: Position of the cursor.
        """
        return self._pos

    def skip_whitespace_and_comments(self) -> None:
        """
        Skips over all whitespace characters and comments.
        :return: None
        """
        while self.in_comment or (self.peek().isspace() and not self.is_escaped):
            self.next()

    def __iter__(self):
        """
        Iterate over all characters. You can use the other methods during the
        iteration, allowing you for example to skip all whitespaces and comments
        in the loop.
        :return: All characters.
        """
        while self.has_next():
            c = self.next()
            yield c


class CommandMatch:
    """
    A match of the CommandFinder.
    """

    def __init__(
        self,
        command: str,
        start: int,
        end: int,
        parameters: typing.List[typing.Tuple[int, int]],
        opt_parameters: typing.List[typing.Optional[typing.Tuple[int, int]]],
    ):
        self.command = command  # command name
        self.start = start  # position of the start of the command
        self.end = end  # position after the last parameter of the command
        self.parameters = parameters  # list of the mandatory parameters
        self.opt_parameters = opt_parameters  # list of the optional parameters

    def __repr__(self):
        return (
            f"{self.start}:{self.end} \\{self.command}"
            + "["
            + "".join(f"{p[0]}:{p[1]}" if p else "None" for p in self.opt_parameters)
            + "]"
            + "".join("{" + str(p[0]) + ":" + str(p[1]) + "}" for p in self.parameters)
        )

    def __eq__(self, other):
        if other is None:
            return False
        return (
            self.command,
            self.start,
            self.end,
            self.parameters,
            self.opt_parameters,
        ) == (
            other.command,
            other.start,
            other.end,
            other.parameters,
            other.opt_parameters,
        )


class CommandFinder:
    """
    Finds occurrences of latex commands in a string and provides you with
    the position and nicely parsed parameters.
    """

    def __init__(self, strict=False):
        self._strict = strict
        self._commands = {}

    def add_command(self, name, num_params=1, num_opt=0):
        """
        :param name:
        :param num_params:
        :param num_opt:
        :return:
        """
        self._commands[name] = (num_params, num_opt)
        return self

    def _read_parameters(self, stream, name: str):
        if name not in self._commands:
            return [], []
        n, n_opt = self._commands[name]
        opt_params = [
            self._read_parameter(stream, "[", "]", False) for _ in range(n_opt)
        ]
        params = [self._read_parameter(stream, "{", "}") for _ in range(n)]
        return opt_params, params

    def _read_command_name(self, stream):
        stream.skip_whitespace_and_comments()
        if stream.peek(True) != "\\":
            msg = f"No command. Next character is '{stream.peek()}'."
            raise _ParserError(msg, stream.pos())
        command = ""
        stream.next()  # skip '\'
        while stream.has_next() and (stream.peek().isalpha() or stream.peek() == "*"):
            command += stream.next()
        return command

    def _read_parameter(
        self, stream: LatexStream, begin: str, end: str, mandatory=True
    ):
        stream.skip_whitespace_and_comments()
        if not mandatory and stream.peek(True) != begin:
            # No begin-symbol ({[) -> no parameter if not mandatory
            return None
        if stream.peek(True) == begin:  # properly encapsulated parameter.
            depth = 1
            stream.advance()
            start = stream.pos()  # after {[
            while depth > 0:
                if stream.peek(True) == begin:
                    depth += 1
                if stream.peek(True) == end:
                    depth -= 1
                stream.advance()
            return (start, stream.pos() - 1)  # at }]
        # parameter without begin/end-symbols ([],{})
        if self._strict:
            context = stream._text[stream.pos() - 10 : stream.pos() + 10]
            msg = f"Parameters without brackets ('{context}')."
            raise _ParserError(msg, stream.pos())
        start = stream.pos()
        if stream.peek() == "\\":
            context = stream._text[stream.pos() - 10 : stream.pos() + 10]
            logging.getLogger("flachtex").warning(
                f"Ambiguous parameters due to missing brackets ('{context}')."
                f" This can lead to corruptions."
            )
            command_name = self._read_command_name(stream)
            self._read_parameters(stream, command_name)
            return (start, stream.pos())
        stream.advance()
        return (start, stream.pos())

    def _read_new_command_parameters(self, stream: LatexStream):
        command_name = self._read_parameter(stream, "{", "}", mandatory=True)
        opt_params = [self._read_parameter(stream, "[", "]", mandatory=False)]
        definition = self._read_parameter(stream, "{", "}", mandatory=True)
        return opt_params, [command_name, definition]

    def find(self, text: str, begin: int = 0) -> typing.Optional[CommandMatch]:
        """
        Find first occurrence of a command in the text.
        :param text: The text to be searched.
        :param begin: The point to start in the text.
        :return:
        """
        stream = LatexStream(text, begin)
        while stream.has_next():
            try:
                if stream.peek(True) == "\\":
                    begin = stream.pos()
                    command = self._read_command_name(stream)
                    if command in (
                        "newcommand",
                        "renewcommand",
                        "newcommand*",
                        "renewcommand*",
                    ):
                        if command in self._commands:
                            opt_params, params = self._read_new_command_parameters(
                                stream
                            )
                            end = stream.pos()
                            return CommandMatch(command, begin, end, params, opt_params)
                        #  In the \\newcommand definition, the commands are not actually
                        # applied, so we want to skip them.
                        self._read_parameter(stream, "{", "}")  # skip definition name
                    elif command in self._commands:
                        opt_params, params = self._read_parameters(stream, command)
                        end = stream.pos()
                        return CommandMatch(command, begin, end, params, opt_params)

                else:
                    stream.next()
            except _ParserError as pe:
                _logger.error(str(pe))
                stream.advance()
        return None

    def find_all(self, text: str):
        begin = 0
        result = self.find(text, begin)
        while result:
            yield result
            begin = result.end
            result = self.find(text, begin)
