"""The command line utility for ChromaTerm"""
import argparse
import os
import re
import select
import signal
import sys

import yaml

from chromaterm import COLOR_TYPES, Color, Rule
from chromaterm import Config as _Config
from chromaterm.default_config import write_default_config

# A couple of sections of the program are used _rarely_ and I don't want to
# _always_ spend time importing them.
# pylint: disable=import-outside-toplevel

# Maximum chuck size per read
READ_SIZE = 4096  # 4 KiB

# CT cannot determine if it is processing input faster than the piping process
# is outputting or if the input has finished. To work around this, CT will wait
# a bit prior to assuming there's no more data in the buffer. There's no impact
# on performance as the wait is cancelled if read_fd becomes ready. 1/256 is
# smaller (shorter) than the fastest key repeat (1/255 second).
WAIT_FOR_SPLIT = 1 / 256

# Select Graphic Rendition sequence (all types)
SGR_RE = re.compile(r'\x1b\[[0-9;]*m')

# Sequences upon which ct will split during processing. This includes new lines,
# vertical spaces, form feeds, C1 set (ECMA-048), CSI (excluding SGR), and OSC.
SPLIT_RE = re.compile(r'(\r\n?|\n|\v|\f|\x1b[\x40-\x5a\x5c\x5e\x5f]|'
                      r'\x1b\x5b[\x30-\x3f]*[\x20-\x2f]*[\x40-\x6c\x6e-\x7e]|'
                      r'\x1b\x5d[\x08-\x0d\x20-\x7e]*(?:\x07|\x1b\x5c))')


class Config(_Config):
    """Improves on the base highlighting of chromaterm.Config by stripping the
    existing color codes from the data before highlighting and keep track of the
    last color between highlights to ensure the correct reset code is used. This
    is helpful when the content of the data to be highlighted is unknown, like
    when the data already has colors."""
    @staticmethod
    def decode_sgr(source_color_code):
        """Decode an SGR, splitting it into discrete colors. Each color is a list
        that contains:
            * color code (str),
            * is reset (bool), and
            * color type (str) which corresponds to chromaterm.COLOR_TYPES.

        Args:
            source_color_code (str): The string to be split into individual SGR codes.

        Returns:
            A list of lists, one for each color.
        """
        def make_sgr(code_id):
            return '\x1b[' + str(code_id) + 'm'

        colors = []
        codes = source_color_code.lstrip('\x1b[').rstrip('m').split(';')
        skip = 0

        for index, code in enumerate(codes):
            # Code processed by an index look-ahead; skip it
            if skip:
                skip -= 1
                continue

            # Full reset
            if code == '' or int(code) == 0:
                colors.append([make_sgr(code), True, None])
            # Multi-code SGR
            elif code in ('38', '48'):
                color_type = 'fg' if code == '38' else 'bg'

                # xterm-256
                if len(codes) > index + 2 and codes[index + 1] == '5':
                    skip = 2
                    code = ';'.join([str(codes[index + x]) for x in range(3)])
                # RGB
                elif len(codes) > index + 4 and codes[index + 1] == '2':
                    skip = 4
                    code = ';'.join([str(codes[index + x]) for x in range(5)])
                # Does not conform to format; do not touch code
                else:
                    return [[source_color_code, False, None]]

                color_code = make_sgr(code)
                is_reset = color_code == COLOR_TYPES[color_type]['reset']

                colors.append([color_code, is_reset, color_type])
            # Single-code SGR
            else:
                color_code = make_sgr(int(code))

                for name in COLOR_TYPES:
                    if COLOR_TYPES[name]['re'].search(color_code):
                        is_reset = color_code == COLOR_TYPES[name]['reset']

                        colors.append([color_code, is_reset, name])

        return colors

    def load(self, data, clear=True, rgb=False):
        """Reads rules from a YAML-based string, formatted like so:
            rules:
            - description: My first rule
              regex: Hello
              color: b#123123
            - description: My second rule is specfic to groups
              regex: W(or)ld
              color:
                0: f#321321
                1: bold

          Any errors are printed to stderr.

          Args:
            data (str): A string containg YAML data.
            clear (bool): Whether to clear the existing rules or not. If data
                could not be parsed as a YAML string, the existing rules are not
                cleared.
            rgb (bool): Whether the terminal is RGB-capable or not.
        """
        # Load the YAML configuration file
        try:
            data = yaml.safe_load(data) or {}
        except yaml.YAMLError as exception:
            eprint('Parse error:', exception)
            return

        if clear:
            self._rules = []

        rules = data.get('rules') if isinstance(data, dict) else None

        for rule in rules if isinstance(rules, list) else []:
            parsed_rule = self.parse_rule(rule, rgb=rgb)

            if isinstance(parsed_rule, Rule):
                self.add_rule(parsed_rule)
            else:
                eprint(parsed_rule)

    @staticmethod
    def parse_rule(rule, rgb=False):
        """Returns an instance of chromaterm.Rule if parsed correctly. Otherwise,
        a string with the error message is returned. The rule is a dictionary
        formatted according to chromaterm.cli.Config.load.

        Args:
            rule (dict): A dictionary representing the rule.
        """
        if not isinstance(rule, dict):
            return 'Rule {} not a dictionary'.format(repr(rule))

        description = rule.get('description')
        regex = rule.get('regex')

        if description:
            rule_repr = 'Rule(regex={}, description={})'.format(
                repr(regex), repr(description))
        else:
            rule_repr = 'Rule(regex={})'.format(repr(regex))

        try:
            parsed_rule = Rule(regex, description=description)
        except TypeError as exception:
            return 'Error on {}: {}'.format(rule_repr, exception)
        except re.error as exception:
            return 'Error on {}: re.error: {}'.format(rule_repr, exception)

        color = rule.get('color')

        if isinstance(color, str):
            color = {0: color}
        elif not isinstance(color, dict):
            return 'Error on {}: color {} is not a string'.format(
                rule_repr, repr(color))

        try:
            for group in color:
                parsed_color = Color(color[group], rgb=rgb)
                parsed_rule.add_color(parsed_color, group=group)
        except (TypeError, ValueError) as exception:
            return 'Error on {}: {}'.format(rule_repr, exception)

        return parsed_rule

    @staticmethod
    def strip_colors(data):
        """Returns data after stripping the existing colors and a list of inserts
        containing the stripped colors. The format of the insert is that of
        chromaterm.Config.get_inserts.

        Args:
            data (str): The string from which the colors should be stripped.
        """
        inserts = []
        match = SGR_RE.search(data)

        while match:
            start, end = match.span()

            for color in Config.decode_sgr(match.group()):
                color.insert(0, start)
                inserts.insert(0, color)

            # Remove match from data; next match's start is in the clean data
            data = data[:start] + data[end:]
            match = SGR_RE.search(data)

        return data, inserts

    def highlight(self, data, force=None):
        """Returns a highlighted version of data.

        Args:
            data (str): A string to highlight. __str__ of data is called.
        """
        if not self.rules:
            return str(data)

        data, inserts = self.strip_colors(str(data))
        inserts = self.get_inserts(data, inserts)

        resets_to_update = list(self._reset_codes)

        for position, color_code, is_reset, color_type in inserts:
            data = data[:position] + color_code + data[position:]

            if resets_to_update:
                # A full reset; default the remaining resets
                if color_type is None and is_reset:
                    for key in resets_to_update:
                        self._reset_codes[key] = COLOR_TYPES[key]['reset']

                    resets_to_update = []
                elif color_type in resets_to_update:
                    self._reset_codes[color_type] = color_code
                    resets_to_update.remove(color_type)

        return data


def args_init(args=None):
    """Returns the parsed arguments (an instance of argparse.Namespace).

    Args:
        args (list): A list of program arguments, Defaults to sys.argv.
    """
    parser = argparse.ArgumentParser()

    parser.add_argument('program',
                        type=str,
                        metavar='program ...',
                        nargs='?',
                        help='run a program with anything after it used as '
                        'arguments')

    parser.add_argument('arguments',
                        type=str,
                        nargs=argparse.REMAINDER,
                        help=argparse.SUPPRESS,
                        default=[])

    parser.add_argument('--config',
                        metavar='FILE',
                        type=str,
                        help='location of config file (default: %(default)s)',
                        default='$HOME/.chromaterm.yml')

    parser.add_argument('--reload',
                        action='store_true',
                        help='Reload the config of all CT instances')

    parser.add_argument('--rgb',
                        action='store_true',
                        help='Use RGB colors (default: detect support, '
                        'fallback to xterm-256)',
                        default=None)

    return parser.parse_args(args=args)


def eprint(*args, **kwargs):
    """Prints a message to stderr."""
    print(sys.argv[0] + ':', *args, file=sys.stderr, **kwargs)


def process_input(config, data_fd, forward_fd=None, max_wait=None):
    """Processes input by reading from data_fd, highlighting it using config,
    then printing it to sys.stdout. If forward_fd is not None, any data it has
    will be written (forwarded) into data_fd.

    Args:
        config (chromaterm.Config): Used for highlighting the data.
        data_fd (int): File descriptor to be read and highlighted.
        forward_fd (int): File descriptor to forwarded into data_fd. This is used
            in conjunction with run_program. None indicates forwarding is not
            required.
        max_wait (float): The maximum time to wait with no data on either of the
            file descriptors. None will block until at least one ready to be read.
    """
    if forward_fd is None:
        fds = [data_fd]
    else:
        fds = [data_fd, forward_fd]

    buffer = ''
    ready_fds = read_ready(*fds, timeout=max_wait)

    while ready_fds:
        # There's some data to forward to the spawned program
        if forward_fd in ready_fds:
            try:
                os.write(data_fd, os.read(forward_fd, READ_SIZE))
            except OSError:
                # spawned program or stdin closed; don't forward anymore
                fds.remove(forward_fd)

        # Data to be highlighted was received
        if data_fd in ready_fds:
            try:
                data = os.read(data_fd, READ_SIZE)
            except OSError:
                data = b''

            buffer += data.decode(encoding='utf-8', errors='replace')

            # Buffer was processed empty and data fd hit EOF
            if not buffer:
                break

            splits = split_buffer(buffer)

            # Process all splits except for the last as might've been cut off
            for data, separator in splits[:-1]:
                sys.stdout.write(config.highlight(data) + separator)

            # Data was read and there's more to come; wait before highlighting
            if data and read_ready(data_fd, timeout=WAIT_FOR_SPLIT):
                buffer = splits[-1][0] + splits[-1][1]
                continue

            # A single character would indicate keyboard typing; don't highlight
            if len(splits[-1][0]) == 1:
                leftover_data = splits[-1][0]
            else:
                leftover_data = config.highlight(splits[-1][0])

            # No more data; print last split and flush (doesn't have a new line)
            sys.stdout.write(leftover_data + splits[-1][1])
            sys.stdout.flush()

            buffer = ''

        ready_fds = read_ready(*fds, timeout=max_wait)


def read_file(location):
    """Returns the contents of a file or None on error. The error is printed to
    stderr.

    Args:
        location (str): The location of the file to be read.
    """
    location = os.path.expandvars(location)

    if not os.access(location, os.F_OK):
        eprint('Configuration file', location, 'not found')
        return None

    try:
        with open(location, 'r') as file:
            return file.read()
    except PermissionError:
        eprint('Cannot read configuration file', location, '(permission)')
        return None


def read_ready(*read_fds, timeout=None):
    """Returns a list of file descriptors that are ready to be read.

    Args:
        *read_fds (int): Integers that refer to the file descriptors.
        timeout (float): A timeout before returning an empty list if no file
            descriptor is ready. None waits until at least one file descriptor
            is ready.
    """
    return [] if not read_fds else select.select(read_fds, [], [], timeout)[0]


def reload_chromaterm_instances():
    """Reloads other ChromaTerm CLI instances by sending them signal.SIGUSR1.

    Returns:
        The number of processes reloaded.
    """
    import psutil

    count = 0
    current_process = psutil.Process()

    for process in psutil.process_iter():
        if process.pid == current_process.pid:  # Skip the current process
            continue

        try:
            # Only compare the first two arguments (Python and script paths)
            if process.cmdline()[:2] == current_process.cmdline()[:2]:
                os.kill(process.pid, signal.SIGUSR1)
                count += 1
        # As per the documentation, expect those errors when accessing the
        # methods of a process
        except (psutil.AccessDenied, psutil.NoSuchProcess):
            pass

    return count


def run_program(program_args):
    """Spawns a program in a pty fork to emulate a controlling terminal.

    Args:
        program_args (list): A list of program arguments. The first argument is
            the program name/location.

    Returns:
        A file descriptor (int) of the mater end of the pty fork.
    """
    import atexit
    import fcntl
    import termios
    import pty
    import shutil
    import struct
    import tty

    # Save the current tty's window size and attributes (used by slave pty)
    window_size = shutil.get_terminal_size()
    window_size = struct.pack('2H', window_size.lines, window_size.columns)

    try:
        attributes = termios.tcgetattr(sys.stdin.fileno())

        # Set to raw as the pty will be handling any processing
        tty.setraw(sys.stdin.fileno())
        atexit.register(termios.tcsetattr, sys.stdin.fileno(), termios.TCSANOW,
                        attributes)
    except termios.error:
        attributes = None

    # openpty, login_tty, then fork
    pid, master_fd = pty.fork()

    if pid == 0:
        # Update the slave's pty (now on std fds) window size and attributes
        fcntl.ioctl(sys.stdin.fileno(), termios.TIOCSWINSZ, window_size)

        if attributes:
            termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, attributes)

        try:
            os.execvp(program_args[0], program_args)
        except FileNotFoundError:
            eprint(program_args[0] + ': command not found')

        # exec replaces the fork's process; only hit on exception
        sys.exit(1)
    else:
        return master_fd


def split_buffer(buffer):
    """Returns a tuples of tuples in the format of (data, separator). data should
    be highlighted while separator should be printed unchanged, after data.

    Args:
        buffer (str): A string to split using SPLIT_RE.
    """
    splits = SPLIT_RE.split(buffer)

    # Append an empty separator in case of no splits or no separator at the end
    splits.append('')

    # Group all splits into format of (data, separator)
    return tuple(zip(splits[0::2], splits[1::2]))


def main(args=None, max_wait=None, stdin_fd=None, write_default=True):
    """Command line utility entry point.

    Args:
        args (list): A list of program arguments. Defaults to sys.argv.
        max_wait (float): The maximum time to wait with no data. None will block
            until data is ready to be read.
        stdin_fd (int): The file descriptor considered as stdin. None will default
            to sys.stdin.fileno().
        write_default (bool): Whether to write the default configuration or not.
            Only written if it doesn't exist already.

    Returns:
        A string indicating status/error. Otherwise, returns None. It is meant to
        be used as sys.exit(chromaterm.cli.main()).
    """
    args = args_init(args)

    if args.reload:
        return 'Processes reloaded: ' + str(reload_chromaterm_instances())

    if write_default:
        # Write default config if not there
        write_default_config()

    config = Config()

    # Create the signal handler to trigger reloading the config
    def reload_config_handler(*_):
        config.load(read_file(args.config) or '', rgb=args.rgb)

    # Trigger the initial loading
    reload_config_handler()

    if args.program:
        # ChromaTerm is spawning the program in a controlling terminal; stdin is
        # being forwarded to the program
        data_fd = run_program([args.program] + args.arguments)
        forward_fd = sys.stdin.fileno() if stdin_fd is None else stdin_fd
    else:
        # Data is being piped into ChromaTerm's stdin; no forwarding needed
        data_fd = sys.stdin.fileno() if stdin_fd is None else stdin_fd
        forward_fd = None

    # Ignore SIGPIPE (broken pipe) and SIGINT (CTRL+C), and attach reload handler
    signal.signal(signal.SIGPIPE, signal.SIG_DFL)
    signal.signal(signal.SIGINT, signal.SIG_IGN)
    signal.signal(signal.SIGUSR1, reload_config_handler)

    # Begin processing the data
    process_input(config, data_fd, forward_fd=forward_fd, max_wait=max_wait)

    return None


if __name__ == '__main__':
    sys.exit(main())
