"""This module contains a definition of Abstract Base Class for file parsers."""
import re
from abc import ABC, abstractmethod
from typing import Callable, Dict, Iterable, Tuple, Type, Union

from ..exceptions import InvalidStateError

_PARSERS: Dict[str, Type["ParserBase"]] = {}


class ParserBase(ABC):
    """Abstract Base Class for parsers implemented as finite state machines.

    This base class defines some methods to organize work parsers implemented as finite
    state machines: automates registration of methods and functions as parser's states,
    manages its execution, and registers derived class as parser used for certain type
    of files (which registry is used by :class:`.Soxhlet` object).

    The default parsing flow goes as follow:

    1. method :meth:`.parse` is called with file handle as argument;
    2. method :meth:`.initial` is set as a 'workhorse'
    3. 'workhorse' is called for consecutive lines in file handle
    4. :meth:`.initial` checks if any registered trigger matches current line
    5. :meth:`.workhorse` is changed to method associated with first matching trigger
    6. calling 'workhorse' on consecutive lines continues
    7. :meth:`.parse` returns dictionary with extracted values

    To make this possible, each method marked as state should return dictionary
    (or sequence convertible to dict) and handle changing 'workhorse' to next
    appropriate state. To mark a method as parser's state use ParserBase.state
    decorator in class definition or add a state directly to parser instance
    using 'add_state' method.

    When subclassing ParserBase, one should implement :meth:`.initial` and
    :meth:`.parse` methods. Those abstract methods implement basic functionality,
    described above. See methods' documentation for more details. If you wish not to use
    default ParserBase's protocol, simply override those methods to your liking. Values
    for class attributes :attr:`.extensions` and :attr:`.purpose` should also be
    provided.

    To register class derived from ParserBase for use by :class:`.Soxhlet` object,
    simply set :attr:`.purpose` class attribute to name, under which class should be
    registered. Setting it to one of names already defined (e.g. 'gaussian') will
    override the default parser used by :class:`.Soxhlet` object.

    Attributes
    ----------
    states: dict
        Dictionary of parser states, created automatically on object
        instantiation from object methods marked as states; method name is used
        as a key by default.
    triggers: dict
        Dictionary of triggers for parser states, created automatically on
        object instantiation from object methods marked as states with triggers;
        key for a particular state trigger should be the same as state's key in
        :attr:`.states` dictionary.
    """

    @property
    @classmethod
    @abstractmethod
    def extensions(cls) -> Tuple[str]:
        return tuple()

    extensions.__doc__ = """File extensions that should be cosidered compatible with a
    parser subclassing :class:`.ParserBase`. It will be used by :class:`.Soxhlet` to
    identify which files to parse when reading files in batch. Should be a class
    attribute with a tuple of str, where each element is a file extension. May also be
    an empty tuple, if files discovery feature is not needed for the parser.
    """

    @property
    @classmethod
    @abstractmethod
    def purpose(cls) -> str:
        return ""

    purpose.__doc__ = """An identifier for a parser subclassing :class:`.ParserBase`. It
    allows ``tesliper`` to pick a correct parser for each parsing task. A falsy value,
    i.e. an empty string or ``None`` prevents the parser from beeing registered for use
    by ``tesliper``. If custom subclass uses a *purpose* already known, e.g. "gaussian"
    or "spectra", it will override the original parser for this purpose.
    """

    def __init__(self):
        self.states = {}
        self.triggers = {}
        states = (
            (name, method)
            for name, method in (
                (n, getattr(self, n)) for n in dir(self) if n != "workhorse"
            )
            if hasattr(method, "is_state") and method.is_state
        )
        for name, method in states:
            self.add_state(method, name, getattr(method, "trigger", None))
        self.workhorse = self.initial

    def __init_subclass__(cls, **kwargs):
        global _PARSERS
        if cls.purpose is ParserBase.purpose:
            raise TypeError(
                f"`{cls.__name__}` must provide `purpose` class attribute. "
                "It may be an empty string if this class should not be registered."
            )
        if cls.extensions is ParserBase.extensions:
            raise TypeError(
                f"`{cls.__name__}` must provide `extensions` class attribute. "
                "It may be an empty tuple if no extensions should be associated with "
                "this class."
            )
        if cls.purpose:
            _PARSERS[cls.purpose] = cls
        if not hasattr(cls.initial, "is_state"):
            cls.initial.is_state = True

    @property
    def workhorse(self) -> Callable:
        """Callable marked as a current state used by parser object.

        Setter can take a callable or a string as a parameter. If name as
        string is passed to setter, it will be translated to a method
        registered as state. If no method was registered under this name,
        :class:`.InvalidStateError` will be raised. No other checks are performed
        when argument is callable."""
        return self._workhorse

    @workhorse.setter
    def workhorse(self, state: Union[Callable, str]):
        if callable(state):
            self._workhorse = state
        else:
            try:
                self._workhorse = self.states[state]
            except KeyError:
                raise InvalidStateError(
                    f"{state} is not callable nor registered state name"
                )

    def add_state(self, state: Callable, name: str = "", trigger: str = ""):
        """Register callable as parser's state.

        This method registers a callable under *name* key in :attr:`.states`
        dictionary. If *trigger* parameter is given, it is registered under the
        same key in :attr:`.triggers` dictionary.

        Parameters
        ----------
        state: Callable
            callable, that is to be registered as parser's state
        name: str, optional
            name under which the callable should be registered;
            defaults to callable.__name__
        trigger: str, optional
            string with regular expression, that will be compiled with re module

        Returns
        -------
        Callable
            callable object registered as state
        """
        if not name:
            name = state.__name__
        self.states[name] = state
        if trigger:
            self.triggers[name] = re.compile(trigger)
        elif hasattr(state, "trigger"):
            self.triggers[name] = re.compile(state.trigger)
        return state

    def remove_state(self, name: str):
        """Removes the state from parser's registered states.

        Parameters
        ----------
        name : str
            name of state, that should be unregistered

        Raises
        ------
        InvalidStateError
            if no callable was registered under the name 'name'
        """
        if name not in self.states:
            raise InvalidStateError(f"No state registered under name: {name}.")
        del self.states[name]
        if name in self.triggers:
            del self.triggers[name]

    @abstractmethod
    def initial(self, line: str) -> dict:
        """An initial parser state.

        A default implementation checks if any of defined triggers matches
        a line and sets an associated state as parser's workhorse, if it does.
        This is an abstract method and should be overridden in subclass.
        Its default implementation can be used, however, by calling
        ``super().initial(line)`` in subclass's method.

        Notes
        -----
        :meth:`.initial` method is always registered as parser's state.

        Parameters
        ----------
        line: str
            currently parsed line

        Returns
        -------
        dict
            empty dictionary"""
        for name, reg in self.triggers.items():
            match = reg.match(line)
            if match:
                self.workhorse = self.states[name]
                return {}
        return {}

    @abstractmethod
    def parse(self, lines: Iterable) -> dict:
        """Parses consecutive elements of iterable and returns data found
        as dictionary.

        Dictionary with extracted data is updated with workhorse's return value,
        so all states should return dictionary or compatible sequence.
        This is an abstract method and should be overridden in subclass.
        Its default implementation can be used, however, by calling
        ``data = super().parse(lines)`` in subclass's method.

        Notes
        -----
        After execution - either successful or interrupted by exception -
        :attr:`.workhorse` is set back to :meth:`.initial` method.

        Parameters
        ----------
        lines: Iterable
            iterable (i.e. file handle), that will be parsed, line by line

        Returns
        -------
        dict
            dictionary with data extracted by parser

        Raises
        ------
        InvalidStateError
            if dictionary can't be updated with state's return value"""
        data = {}
        try:
            for line in lines:
                output = self.workhorse(line)
                try:
                    data.update(output)
                except TypeError as error:
                    raise InvalidStateError(
                        f"State {self.workhorse} should return value "
                        f'convertible to a dictionary, not "{type(output)}".'
                    ) from error
                except ValueError as error:
                    raise InvalidStateError(
                        f"Value returned by state {self.workhorse} could not "
                        f"be converted to dictionary."
                    ) from error
        except Exception:
            raise
        finally:
            self.workhorse = self.initial
        return data

    @staticmethod
    def state(state=None, trigger=None):
        """Convenience decorator for registering a method as parser's state.
        It can be with or without 'trigger' parameter, like this:

        >>> @ParserBase.state
        ... def method(self, arg): pass

        or

        >>> @ParserBase.state(trigger='triggering regex')
        ... def method(self, arg): pass

        This function marks a method *state* as parser's state by defining ``is_state``
        attribute on said method and setting its values to ``True``. If *trigger* is
        given, it is stored in method's attribute *trigger*. During instantiation of
        :class:`.ParserBase`'s subclass, methods marked as states are registered under
        ``method.__name__`` key in its :attr:`.states` (and possibly :attr:`.triggers`)
        attribute. It is meaningless if used outside of :class:`.ParserBase`'s subclass
        definition.

        Parameters
        ----------
        state: Callable
            callable, that is to be registered as parser's state
        trigger: str, optional
            string with regular expression, that will be compiled with re module

        Returns
        -------
        Callable
            callable object registered as state if 'state' was given or
            decorator if only 'trigger' was given

        Raises
        ------
        TypeError
            if no arguments given
        InvalidStateError
            if *state* argument is not callable
        """
        if callable(state):
            state.is_state = True
            if trigger:
                state.trigger = trigger
            return state
        elif state is None and trigger is None:
            raise TypeError("At least one argument must be given.")
        elif trigger is None:
            raise InvalidStateError(
                f"'state' argument should be callable, not {type(state)}"
            )
        else:
            return lambda s, t=trigger: ParserBase.state(s, t)
