# SVG Path specification parser

from typing import Generator, Tuple, Union
import re
from svg.path import path

COMMANDS = set("MmZzLlHhVvCcSsQqTtAa")
UPPERCASE = set("MZLHVCSQTA")

COMMAND_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])")
FLOAT_RE = re.compile(rb"^[-+]?\d*\.?\d*(?:[eE][-+]?\d+)?")


class InvalidPathError(ValueError):
    pass


# The argument sequences from the grammar, made sane.
# u: Non-negative number
# s: Signed number or coordinate
# c: coordinate-pair, which is two coordinates/numbers, separated by whitespace
# f: A one character flag, doesn't need whitespace, 1 or 0
ARGUMENT_SEQUENCE = {
    "M": "c",
    "Z": "",
    "L": "c",
    "H": "s",
    "V": "s",
    "C": "ccc",
    "S": "cc",
    "Q": "cc",
    "T": "c",
    "A": "uusffc",
}


def strip_array(arg_array: bytearray) -> None:
    """Strips whitespace and commas"""
    # EBNF wsp:(#x20 | #x9 | #xD | #xA) + comma: 0x2C
    while arg_array and arg_array[0] in (0x20, 0x9, 0xD, 0xA, 0x2C):
        arg_array[0:1] = b""


def pop_number(arg_array: bytearray) -> float:
    res = FLOAT_RE.search(arg_array)
    if not res or not res.group():
        raise InvalidPathError(f"Expected a number, got '{arg_array}'.")
    number = float(res.group())
    start = res.start()
    end = res.end()
    arg_array[start:end] = b""
    strip_array(arg_array)

    return number


def pop_unsigned_number(arg_array: bytearray) -> float:
    number = pop_number(arg_array)
    if number < 0:
        raise InvalidPathError(f"Expected a non-negative number, got '{number}'.")
    return number


def pop_coordinate_pair(arg_array: bytearray) -> complex:
    x = pop_number(arg_array)
    y = pop_number(arg_array)
    return complex(x, y)


def pop_flag(arg_array: bytearray) -> Union[bool, None]:
    flag = arg_array[0]
    arg_array[0:1] = b""
    strip_array(arg_array)
    if flag == 48:  # ASCII 0
        return False
    if flag == 49:  # ASCII 1
        return True
    return None


FIELD_POPPERS = {
    "u": pop_unsigned_number,
    "s": pop_number,
    "c": pop_coordinate_pair,
    "f": pop_flag,
}


def _commandify_path(pathdef: str) -> Generator[Tuple[str, ...], None, None]:
    """Splits path into commands and arguments"""
    token: Union[Tuple[str, ...], None] = None
    for x in COMMAND_RE.split(pathdef):
        x = x.strip()
        if x in COMMANDS:
            if token is not None:
                yield token
            if x in ("z", "Z"):
                # The end command takes no arguments, so add a blank one
                token = (x, "")
            else:
                token = (x,)
        elif x:
            if token is None:
                raise InvalidPathError(f"Path does not start with a command: {pathdef}")
            token += (x,)
    # Logically token cannot be None, but mypy cannot deduce this.
    if token is not None:
        yield token


def _tokenize_path(
    pathdef: str,
) -> Generator[Tuple[Union[str, complex, float, bool, None], ...], None, None]:
    for command, args in _commandify_path(pathdef):
        # Shortcut this for the close command, that doesn't have arguments:
        if command in ("z", "Z"):
            yield (command,)
            continue

        # For the rest of the commands, we parse the arguments and
        # yield one command per full set of arguments
        arg_sequence = ARGUMENT_SEQUENCE[command.upper()]
        arguments = bytearray(args, "ascii")
        implicit = False
        while arguments:
            command_arguments = []
            for i, arg in enumerate(arg_sequence):
                try:
                    command_arguments.append(FIELD_POPPERS[arg](arguments))
                except InvalidPathError as e:
                    if i == 0 and implicit:
                        return  # Invalid character in path, treat like a comment
                    raise InvalidPathError(
                        f"Invalid path element {command} {args}"
                    ) from e

            yield (command,) + tuple(command_arguments)
            implicit = True

            # Implicit Moveto commands should be treated as Lineto commands.
            if command == "m":
                command = "l"
            elif command == "M":
                command = "L"


def parse_path(pathdef: str) -> path.Path:
    segments = path.Path()
    start_pos = None
    last_command = "No last command"
    current_pos = 0j

    for token in _tokenize_path(pathdef):
        command = token[0]
        assert isinstance(command, str)
        relative = command.islower()
        command = command.upper()
        if command == "M":
            pos = token[1]
            assert isinstance(pos, complex)
            if relative:
                current_pos += pos
            else:
                current_pos = pos
            segments.append(path.Move(current_pos, relative=relative))
            start_pos = current_pos

        elif command == "Z":
            # For Close commands the "relative" argument just preserves case,
            # it has no different in behavior.
            assert isinstance(start_pos, complex)
            segments.append(path.Close(current_pos, start_pos, relative=relative))
            current_pos = start_pos

        elif command == "L":
            pos = token[1]
            assert isinstance(pos, complex)
            if relative:
                pos += current_pos
            segments.append(path.Line(current_pos, pos, relative=relative))
            current_pos = pos

        elif command == "H":
            hpos = token[1]
            assert isinstance(hpos, float)
            if relative:
                hpos += current_pos.real
            pos = complex(hpos, current_pos.imag)
            segments.append(
                path.Line(current_pos, pos, relative=relative, horizontal=True)
            )
            current_pos = pos

        elif command == "V":
            vpos = token[1]
            assert isinstance(vpos, float)
            if relative:
                vpos += current_pos.imag
            pos = complex(current_pos.real, vpos)
            segments.append(
                path.Line(current_pos, pos, relative=relative, vertical=True)
            )
            current_pos = pos

        elif command == "C":
            control1 = token[1]
            assert isinstance(control1, complex)
            control2 = token[2]
            assert isinstance(control2, complex)
            end = token[3]
            assert isinstance(end, complex)

            if relative:
                control1 += current_pos
                control2 += current_pos
                end += current_pos

            segments.append(
                path.CubicBezier(
                    current_pos, control1, control2, end, relative=relative
                )
            )
            current_pos = end

        elif command == "S":
            # Smooth curve. First control point is the "reflection" of
            # the second control point in the previous path.
            control2 = token[1]
            assert isinstance(control2, complex)
            end = token[2]
            assert isinstance(end, complex)

            if relative:
                control2 += current_pos
                end += current_pos

            if last_command in "CS":
                # The first control point is assumed to be the reflection of
                # the second control point on the previous command relative
                # to the current point.
                assert isinstance(segments[-1], path.CubicBezier)
                control1 = current_pos + current_pos - segments[-1].control2
            else:
                # If there is no previous command or if the previous command
                # was not an C, c, S or s, assume the first control point is
                # coincident with the current point.
                control1 = current_pos

            segments.append(
                path.CubicBezier(
                    current_pos, control1, control2, end, relative=relative, smooth=True
                )
            )
            current_pos = end

        elif command == "Q":
            control = token[1]
            assert isinstance(control, complex)
            end = token[2]
            assert isinstance(end, complex)

            if relative:
                control += current_pos
                end += current_pos

            segments.append(
                path.QuadraticBezier(current_pos, control, end, relative=relative)
            )
            current_pos = end

        elif command == "T":
            # Smooth curve. Control point is the "reflection" of
            # the second control point in the previous path.
            end = token[1]
            assert isinstance(end, complex)

            if relative:
                end += current_pos

            if last_command in "QT":
                # The control point is assumed to be the reflection of
                # the control point on the previous command relative
                # to the current point.
                assert isinstance(segments[-1], path.QuadraticBezier)
                control = current_pos + current_pos - segments[-1].control
            else:
                # If there is no previous command or if the previous command
                # was not an Q, q, T or t, assume the first control point is
                # coincident with the current point.
                control = current_pos

            segments.append(
                path.QuadraticBezier(
                    current_pos, control, end, smooth=True, relative=relative
                )
            )
            current_pos = end

        elif command == "A":
            # For some reason I implemented the Arc with a complex radius.
            # That doesn't really make much sense, but... *shrugs*
            assert isinstance(token[1], float)
            assert isinstance(token[2], float)
            radius = complex(token[1], token[2])
            rotation = token[3]
            assert isinstance(rotation, float)
            arc = token[4]
            assert isinstance(arc, (bool, int))
            sweep = token[5]
            assert isinstance(sweep, (bool, int))
            end = token[6]
            assert isinstance(end, complex)

            if relative:
                end += current_pos

            segments.append(
                path.Arc(
                    current_pos, radius, rotation, arc, sweep, end, relative=relative
                )
            )
            current_pos = end

        # Finish up the loop in preparation for next command
        last_command = command

    return segments
