
"""
This module provides parsers to create ColouredText objects from embedded control strings.
"""
from __future__ import division
from __future__ import absolute_import
from __future__ import print_function
from __future__ import unicode_literals
import re
from builtins import str
from future.utils import with_metaclass
from abc import ABCMeta, abstractmethod
from logging import getLogger
import asciimatics.constants as constants
from asciimatics.utilities import _DotDict


# Diagnostic logging
logger = getLogger(__name__)


class Parser(with_metaclass(ABCMeta, object)):
    """
    Abstract class to represent text parsers that extract colour control codes from raw text and
    convert them to displayable text and associated colour maps.
    """

    #: Command to display some text.  Parameter is the text to display
    DISPLAY_TEXT = 0
    #: Command to change active colour tuple.  Parameters are the 3-tuple of (fg, attr, bg)
    CHANGE_COLOURS = 1
    #: Command to move cursor to abs position.  Parameters are (x, y) where each are absolute positions.
    MOVE_ABSOLUTE = 2
    #: Command to move cursor to relative position.  Parameters are (x, y) where each are relative positions.
    MOVE_RELATIVE = 3
    #: Command to delete part of the current line. Params are 0, 1 and 2 for end, start, all.
    DELETE_LINE = 4
    #: Command to delete next N characters from this line.
    DELETE_CHARS = 5
    #: Next tab stop
    NEXT_TAB = 6
    #: Set cursor visibility.  Param is boolean setting True=visible
    SHOW_CURSOR = 7
    #: Clear the screen.  No parameters.
    CLEAR_SCREEN = 8
    #: Save the cursor position.  No parameters.
    SAVE_CURSOR = 9
    #: Restore the cursor position.  No parameters.
    RESTORE_CURSOR = 10

    def __init__(self):
        """
        Initialize the parser.
        """
        self._state = None

    def reset(self, text, colours):
        """
        Reset the parser to analyze the supplied raw text.

        :param text: raw text to process.
        :param colours: colour tuple to initialise the colour map.
        """
        self._state = _DotDict()
        self._state.text = text
        # Force colours to be mutable (in case a tuple was passed in).
        self._state.attributes = [x for x in colours] if colours else None

    @abstractmethod
    def parse(self):
        """
        Generator to return coloured text from raw text.

        Generally returns a stream of text/color tuple/offset tuples.  If there is a colour update with no
        visible text, the first element of the tuple may be None.

        :returns: a 3-tuple of (start offset in raw text, command to execute, parameters)
        """

    def append(self, text):
        """
        Append more text to the current text being processed.

        :param text: raw text to process.
        """
        self._state.text += text


class ControlCodeParser(Parser):
    """
    Parser to replace all control codes with a readable version - e.g. "^M" for carriage return.
    """

    def parse(self):
        """
        Generator to return coloured text from raw text.

        :returns: a 3-tuple of (start offset in raw text, command to execute, parameters)
        """
        if self._state.attributes:
            yield (0, Parser.CHANGE_COLOURS, tuple(self._attributes))
        offset = 0
        while len(self._state.text) > 0:
            letter = self._state.text[0]
            if ord(letter) < 32:
                yield (offset, Parser.DISPLAY_TEXT, "^" + chr(ord("@") + ord(letter)))
            else:
                yield (offset, Parser.DISPLAY_TEXT, letter)
            offset += 1
            self._state.text = self._state.text[1:]


class AsciimaticsParser(Parser):
    """
    Parser to handle Asciimatics rendering escape strings.
    """

    # Regular expression for use to find colour sequences in multi-colour text.
    # It should match ${n}, ${m,n} or ${m,n,o}
    _colour_sequence = re.compile(constants.COLOUR_REGEX)

    def parse(self):
        """
        Generator to return coloured text from raw text.

        :returns: a 3-tuple of (start offset in raw text, command to execute, parameters)
        """
        if self._state.attributes:
            yield (0, Parser.CHANGE_COLOURS, tuple(self._state.attributes))
        offset = last_offset = 0
        while len(self._state.text) > 0:
            match = self._colour_sequence.match(str(self._state.text))
            if match is None:
                yield (last_offset, Parser.DISPLAY_TEXT, self._state.text[0])
                self._state.text = self._state.text[1:]
                offset += 1
                last_offset = offset
            else:
                # The regexp either matches:
                # - 2,3,4 for ${c,a,b}
                # - 5,6 for ${c,a}
                # - 7 for ${c}.
                if match.group(2) is not None:
                    attributes = (int(match.group(2)),
                                  constants.MAPPING_ATTRIBUTES[match.group(3)],
                                  int(match.group(4)))
                elif match.group(5) is not None:
                    attributes = (int(match.group(5)),
                                  constants.MAPPING_ATTRIBUTES[match.group(6)],
                                  None)
                else:
                    attributes = (int(match.group(7)), 0, None)
                yield (last_offset, Parser.CHANGE_COLOURS, attributes)
                offset += 3 + len(match.group(1))
                self._state.text = match.group(8)


class AnsiTerminalParser(Parser):
    """
    Parser to handle ANSI terminal escape codes.
    """

    # Regular expression for use to find colour sequences in multi-colour text.
    _colour_sequence = re.compile(r"^(\x1B\[([^@-~]*)([@-~]))(.*)")
    _os_cmd = re.compile(r"^(\x1B].*\x07)(.*)")

    def reset(self, text, colours):
        """
        Reset the parser to analyze the supplied raw text.

        :param text: raw text to process.
        :param colours: colour tuple to initialise the colour map.
        """
        super(AnsiTerminalParser, self).reset(text, colours)
        if self._state.attributes is None:
            self._state.init_colours = False
            self._state.attributes = [None, None, None]
        else:
            self._state.init_colours = True
        self._state.offset = 0
        self._state.last_offset = 0
        self._state.cursor = 0

    def parse(self):
        def _handle_escape(st):
            match = self._colour_sequence.match(str(st.text))
            if match is None:
                # Not a CSI sequence... Check for some other options.
                match = self._os_cmd.match(str(st.text))
                if match:
                    # OS command - just swallow it.
                    return len(match.group(1)), None
                elif len(st.text) > 1 and st.text[1] == "M":
                    # Reverse Index - i.e. move up/scroll
                    return 2, [(st.last_offset, Parser.MOVE_RELATIVE, (0, -1))]

                # Unknown escape - guess how many characters to ignore - most likely just the next char
                # unless we can see the start of a new sequence.
                logger.debug("Ignoring: %s", st.text[0:2])
                if len(st.text) < 2:
                    return -1, None
                if st.text[1] in ("[", "]"):
                    return -1, None
                return (2, None) if st.text[1] != "(" else (3, None)
            else:
                # CSI sequence - look for the various options...
                results = []
                if match.group(3) == "m":
                    # We have found a SGR escape sequence ( CSI ... m ).  These have zero or more
                    # embedded arguments, so create a simple FSM to process the parameter stream.
                    in_set_mode = False
                    in_index_mode = False
                    in_rgb_mode = False
                    skip_size = 0
                    attribute_index = 0
                    last_attributes = tuple(st.attributes)
                    for parameter in match.group(2).split(";"):
                        try:
                            parameter = int(parameter)
                        except ValueError:
                            parameter = 0
                        if in_set_mode:
                            # We are processing a set fore/background colour code
                            if parameter == 5:
                                in_index_mode = True
                            elif parameter == 2:
                                in_rgb_mode = True
                                skip_size = 3
                            else:
                                logger.info(("Unexpected colour setting", parameter))
                                break
                            in_set_mode = False
                        elif in_index_mode:
                            # We are processing a 5;n sequence for colour indeces
                            st.attributes[attribute_index] = parameter
                            in_index_mode = False
                        elif in_rgb_mode:
                            # We are processing a 2;r;g;b sequence for RGB colours - just ignore.
                            skip_size -= 1
                            if skip_size <= 0:
                                in_rgb_mode = False
                        else:
                            # top-level stream processing
                            if parameter == 0:
                                # Reset
                                st.attributes = [constants.COLOUR_WHITE,
                                                 constants.A_NORMAL,
                                                 constants.COLOUR_BLACK]
                            elif parameter == 1:
                                # Bold
                                st.attributes[1] = constants.A_BOLD
                            elif parameter in (2, 22):
                                # Faint/normal - faint not supported so treat as normal
                                st.attributes[1] = constants.A_NORMAL
                            elif parameter == 7:
                                # Inverse
                                st.attributes[1] = constants.A_REVERSE
                            elif parameter == 27:
                                # Inverse off - assume that means normal
                                st.attributes[1] = constants.A_NORMAL
                            elif parameter in range(30, 38):
                                # Standard foreground colours
                                st.attributes[0] = parameter - 30
                            elif parameter in range(40, 48):
                                # Standard background colours
                                st.attributes[2] = parameter - 40
                            elif parameter == 38:
                                # Set foreground colour - next parameter is either 5 (index) or 2 (RGB color)
                                in_set_mode = True
                                attribute_index = 0
                            elif parameter == 48:
                                # Set background colour - next parameter is either 5 (index) or 2 (RGB color)
                                in_set_mode = True
                                attribute_index = 2
                            elif parameter == 39:
                                # Default foreground colour
                                st.attributes[0] = -1
                            elif parameter == 49:
                                # Default background colour
                                st.attributes[2] = -1
                            elif parameter in range(90, 98):
                                # Bright foreground colours
                                st.attributes[0] = parameter - 82
                            elif parameter in range(100, 108):
                                # Bright background colours
                                st.attributes[2] = parameter - 92
                            else:
                                logger.debug("Ignoring parameter: %s", parameter)
                    new_attributes = tuple(st.attributes)
                    if last_attributes != new_attributes:
                        results.append((st.last_offset, Parser.CHANGE_COLOURS, new_attributes))
                elif match.group(3) == "K":
                    # This is a line delete sequence.  Parameter defines which parts to delete.
                    param = match.group(2)
                    if param in ("", "0"):
                        # Delete to end of line
                        results.append((self._state.last_offset, Parser.DELETE_LINE, 0))
                    elif param == "1":
                        # Delete from start of line
                        results.append((self._state.last_offset, Parser.DELETE_LINE, 1))
                    elif param == "2":
                        # Delete whole line
                        results.append((self._state.last_offset, Parser.DELETE_LINE, 2))
                elif match.group(3) == "P":
                    # This is a character delete sequence.  Parameter defines how many to delete.
                    param = 1 if match.group(2) == "" else int(match.group(2))
                    results.append((self._state.last_offset, Parser.DELETE_CHARS, param))
                elif match.group(3) == "A":
                    # Move cursor up.  Parameter defines how far to move..
                    param = 1 if match.group(2) == "" else int(match.group(2))
                    results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (0, -param)))
                elif match.group(3) == "B":
                    # Move cursor down.  Parameter defines how far to move..
                    param = 1 if match.group(2) == "" else int(match.group(2))
                    results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (0, param)))
                elif match.group(3) == "C":
                    # Move cursor forwards.  Parameter defines how far to move..
                    param = 1 if match.group(2) == "" else int(match.group(2))
                    results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (param, 0)))
                elif match.group(3) == "D":
                    # Move cursor backwards.  Parameter defines how far to move..
                    param = 1 if match.group(2) == "" else int(match.group(2))
                    results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (-param, 0)))
                elif match.group(3) == "H":
                    # Move cursor to specified position.
                    x, y = 0, 0
                    params = match.group(2).split(";")
                    y = int(params[0]) - 1 if params[0] != "" else 0
                    if len(params) > 1:
                        x = int(params[1]) - 1 if params[1] != "" else 0
                    results.append((self._state.last_offset, Parser.MOVE_ABSOLUTE, (x, y)))
                elif match.group(3) == "h" and match.group(2) == "?25":
                    # Various DEC private mode commands - look for cursor visibility, ignore others.
                    results.append((self._state.last_offset, Parser.SHOW_CURSOR, True))
                elif match.group(3) == "l" and match.group(2) == "?25":
                    # Various DEC private mode commands - look for cursor visibility, ignore others.
                    results.append((self._state.last_offset, Parser.SHOW_CURSOR, False))
                elif match.group(3) == "h" and match.group(2) == "?1049":
                    # This should really create an alternate screen, but clearing is a close
                    # approximation
                    results.append((self._state.last_offset, Parser.CLEAR_SCREEN, None))
                elif match.group(3) == "l" and match.group(2) == "?1049":
                    # This should really return to the normal screen, but clearing is a close
                    # approximation
                    results.append((self._state.last_offset, Parser.CLEAR_SCREEN, None))
                elif match.group(3) == "J" and match.group(2) == "2":
                    # Clear the screen.
                    results.append((self._state.last_offset, Parser.CLEAR_SCREEN, None))
                elif match.group(3) == "s":
                    # Save cursor pos
                    results.append((self._state.last_offset, Parser.SAVE_CURSOR, None))
                elif match.group(3) == "u":
                    # Restore cursor pos
                    results.append((self._state.last_offset, Parser.RESTORE_CURSOR, None))
                else:
                    logger.debug("Ignoring control: %s", match.group(1))
                return len(match.group(1)), results

        if self._state.init_colours:
            self._state.init_colours = False
            yield (0, Parser.CHANGE_COLOURS, self._state.attributes)
        while len(self._state.text) > 0:
            char = ord(self._state.text[0])
            new_offset = 1
            if char > 31:
                yield (self._state.last_offset, Parser.DISPLAY_TEXT, self._state.text[0])
                self._state.last_offset = self._state.offset + 1
            elif char == 7:
                # Bell - ignore
                pass
            elif char == 8:
                # Back space
                yield (self._state.last_offset, Parser.MOVE_RELATIVE, (-1, 0))
            elif char == 9:
                # Tab
                yield (self._state.last_offset, Parser.NEXT_TAB, None)
            elif char == 13:
                # Carriage return
                yield (self._state.last_offset, Parser.MOVE_ABSOLUTE, (0, None))
            elif char == 27:
                new_offset, results = _handle_escape(self._state)
                if new_offset == -1:
                    break
                if results is not None:
                    for result in results:
                        yield result
            else:
                logger.debug("Ignoring character: %d", char)
                yield (self._state.last_offset, Parser.DISPLAY_TEXT, " ")
                self._state.last_offset = self._state.offset + 1
            self._state.offset += new_offset
            self._state.text = self._state.text[new_offset:]
