from typing import List, Callable, Any, Union
import os
import sys
import dis
import traceback
import inspect
from datetime import datetime
import jsonpickle
import re
import threading
from pathlib import Path
from contextlib import contextmanager
from dblogging.config import LogTag, LogTags
from dblogging.generators import HtmlLogGenerator
from dblogging.sqlite.dal import LoggerSql


class _LogThread:
    def __init__(self, depth: int = -1, sql: LoggerSql = None, blacklist_function: Callable = None,
                 mode: str = 'all'):
        self.depth = depth
        self.sql = sql
        self._mode = mode
        self.persistence_enabled = mode in {'all', 'persistence'}
        self.console_enabled = mode in {'all', 'console'}
        self.blacklist_function = blacklist_function

    @property
    def mode(self):
        return self._mode

    @mode.setter
    def mode(self, value: str):
        if value == 'current':
            return
        elif value not in (values := {'all', 'persistence', 'console'}):
            raise ValueError(f'Cannot set mode to "{value}" because it is invalid.\n'
                             f'Valid modes are {values}')
        self._mode = value
        self.persistence_enabled = value in {'all', 'persistence'}
        self.console_enabled = value in {'all', 'console'}


def singleton(cls):
    instances = {}

    def _singleton(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]

    return _singleton


@singleton
class Logger:
    def __init__(self):
        """
        Creates a singleton instance of the logger. This object is a singleton object because different
        instances of the logger can create issues if persisting to the same file. Rather than having
        multiple instances of the logger, a single instance can be used while using log tags.

        --> Log Tags

        Log tags are a conceptual representation of how the program can be divided in terms of logging. Common
        tags are severity tags: info, debug, warning, error, and critical. However, custom tags can be created
        to represent the logging in more and different ways. Rather than severity tags, tags could alternatively
        represent the level of code that is performing the log, such as the Data Access Layer vs. a public API
        layer. Log tags provide a way to tag log messages so they can be more easily filtered when viewing the
        output.

        To create custom log tags, either import the LogTagTemplate or the LogTags and inherit it in the new
        custom log tag class. A ``default`` and ``critical`` log tags are required because the logger uses
        defaults for standard and error logging. The LogTagTemplate does not define any of the required tags,
        but raises an error when they are not implemented whereas the LogTags does. Here is an example:

            from logger.config import LogTagTemplate, LogTags, LogTag

            # Inheriting from LogTagTemplate requires default and critical tags to be defined.
            class CustomLogTags1(LogTagTemplate):
                default = LogTag(
                    name='Standard',
                    value=0,
                    html_color='cyan'
                )

                test = LogTag(
                    name='Test',
                    value=10,
                    html_color='green'
                )

                critical = LogTag(
                    name='Critical',
                    value=90,
                    html_color='red'
                )

            # Inheriting from LogTags doesn't require default and critical tags to be defined.
            class CustomLogTags2(LogTags):
                test = LogTag(
                    name='Test',
                    value=10,
                    html_color='green'
                )

        To use the custom log tags, set the ``log_tags`` to the class that defines them. Using the
        example above, ``logger.log_tags = CustomLogTags1``. Then ``logger.log(msg, CustomLogTags1.test)``
        would be legal.

        --> Basic Logging

        There are three basic logging methods: ``log()``, ``log_method()``, and ``log_exception()``.

        ``log()`` simply commits a log message on the fly. The function name is retrieved dynamically.
        ``log_method()`` should at most rarely be used. It creates a log entry with a given function name.
        ``log_exception()`` is used to locate the exception in the call stack to log.

        --> Logging Control

        Logging output can be controlled by setting rules once the ``start()`` method is called. These
        rules are set using the ``set_rule()`` method. These rules control a few factors when logging:
            a) The mode. There are four modes: current, console, persistence, and all. The console mode
               only logs to stdout. The persistence mode only logs to the DB log file. All does both. The
               current mode does not switch the mode when setting the rule.
            b) The rule. Basic rules are setting a minimum tag value or blacklisting a set of tag names.
               A custom rule can be supplied as well. This controls what will and won't get logged to
               reduce noise in the logs.
            c) Why. This is a message suggesting why the rule was changed. This will always be logged.

        --> Contextual Logging

        Use ``generate()`` as a context manager (i.e. ``with logger.generate('html'):``). Nothing is yielded
        to the context block. After the context is executed, the logger automatically searches the call stack
        if an error is raised and logs it. If persistence is enabled, the format generator's ``generate()``
        method is called to create the log file from the DB file. HTML is the only generator provided, although
        a custom generator can be created.

        --> Log Wrappers

        There are two log wrappers: wrap_class() and wrap_func(). They log the inputs and outputs of functions.
        They provided depth to the log output, meaning a hierarchy of logs can be created. Without these methods
        all logs would be flat and one dimensional. See their docstrings for more details.
        """
        self._disabled = True
        self._date_format = '%Y/%m/%d %H:%M:%S'
        self._timestamp = lambda: datetime.now().strftime(self._date_format)
        self._persistent_logging = False
        self._log_path = None
        self._log_tags = LogTags
        self._sql = None
        self._main_thread = threading.current_thread()
        self._threads = {self._main_thread.ident: _LogThread(depth=0)}
        self._jsonpickle = jsonpickle
        self._jsonpickle.set_encoder_options('json', sort_keys=True, indent=4)
        self.logger_lock = threading.Lock()

    @property
    def log_path(self) -> Path:
        return self._log_path

    @log_path.setter
    def log_path(self, path: Union[Path, str]):
        if self._log_path and os.path.exists(self._log_path):
            raise ValueError(f'Cannot set log path to "{path}" because it is already set to '
                             f'"{self._log_path}", which exists.')
        if not isinstance(path, Path) and not path.endswith('.db'):
            path += '.db'
            path = Path(path)
        self._log_path = path
        self._persistent_logging = True

    @property
    def log_tags(self):
        return self._log_tags

    @log_tags.setter
    def log_tags(self, value):
        if not hasattr(value, 'default'):
            raise AttributeError(f'The custom logger must have a <default> LogTag attribute defined.')
        elif not hasattr(value, 'critical'):
            raise AttributeError(f'The custom logger must have a <critical> LogTag attribute defined.')
        self._log_tags = value

    @property
    def date_format(self):
        return self._date_format

    @date_format.setter
    def date_format(self, format: str):
        self._date_format = format

    def start(self):
        """
        Starts the logging. By default all logging is disabled. Once this method is called, logging
        is initialized according to the log path, log tags, and date format set. By default, nothing
        is set. These variables can be changed throughout the execution of the program, although it
        is not recommended as the output can produce unexpected results.

        If nothing is set and this method is called, only stdout is enabled. No logs will persist.

        The ``log_path`` can change throughout the execution of the program given that the file does
        not already exist (but the parent folder must exist). Once set, call ``start()`` again to
        reinitialize the DB file.

        The ``log_tags`` can change, although this is dangerous and should not happen. Once this
        method is called, the DB is initialized with all of the log tag data. If the log tags
        change during execution of the program and contains tags that did not exist previously,
        then they will not be included in the output logs.

        The ``date_format`` can change any time as this only affects stdout.
        """
        self._disabled = False
        if self.log_path:
            self._sql = LoggerSql(db_file_name=self._log_path)
            self._threads = {
                self._main_thread.ident: _LogThread(
                    depth=0,
                )
            }

            self._sql.create_database()
            for ll in self._log_tags.get_all():
                self._sql.log_tags.insert(
                    name=ll.name, value=ll.value, color=ll.html_color
                )

    @contextmanager
    def generate(self, format_generator: Union[str, Callable], **kwargs):
        """
        Generates logs and optionally generates an output file from the DB log file according
        to the ``format_generator`` parameter. The ``format_generator`` is either a string
        that identifies a built-in format generator or a custom generator that is callable.

        The only built-in generator is 'html' at this time.

        A custom generator can be used. It must be able to be initialized without parameters
        and have a ``generate()`` function with at least a ``log_file`` parameter. Optional
        parameters can be passed to the generator as ``kwargs``. For example:

            # Using the built-in HTML generator.
            with logger.generate('html'):
                # Do stuff here.

            # Using a custom generator.
            class SomeCustomGenerator:
                def __init__(self):  # No arguments required!
                    # Initialize stuff here.

                # This method is required!
                def generate(log_file: str, **kwargs): # log_file required!
                    # Generate output here.

            with logger.generate(SomeCustomGenerator, custom_kw='Some Custom Variable Value'):
                # Do stuff here.

        Args:
            format_generator: Either a string for a built-in generator or a callable custom one.
            **kwargs: Extra arguments to pass to the generator.
        """
        if self._disabled:
            self.start()
        if self._persistent_logging:
            if isinstance(format_generator, str):
                generators = {
                'html': HtmlLogGenerator
                }
                generator = generators[format_generator]()
            else:
                generator = format_generator()
            if not self._log_path:
                raise ValueError('Cannot generate persisting logs without a log path.\n'
                                 'Please instantiate the logger with a log path.')
            try:
                if not os.path.exists(self._log_path.parent):
                    file_path = ''
                    for path in self._log_path.parts:
                        file_path += path + os.sep
                        if not os.path.exists(file_path):
                            os.mkdir(file_path)
                yield
            except:
                self.log_exception()
                raise
            finally:
                generator.generate(log_file=self._log_path, **kwargs)
        else:
            try:
                yield
            except:
                self.log_exception()

    def set_rule(self, mode: str = 'current', log_tag: LogTag = None, min_tag_value: Union[LogTag, int] = None,
                 blacklist_tag_names: List[str] = None, blacklist_function: Callable = None,
                 reset: bool = False, why: str = ''):
        """
        Sets a rule that dictates what will and will not get logged, as well as how they are logged.

        The log mode can be used to change where the logs are recorded. This is useful for simply
        reducing noise in the log output.
        There are four modes: current, console, persistence, and all.
            * current: Do not change the mode. Default.
            * console: Change the mode to log to the console only.
            * persistence: Change the mode to log to the SQLite DB file only.
            * all: Change the mode to use both console and persistent logging.

        The ``min_tag_value`` specifies the minimum tag value that a log tag must have in order to
        log a message.

        The ``blacklist_tag_names`` specifies a list of tag names that prevent messages from being
        logged. In other words, a log tag whose name is blacklisted will not log the message.

        The ``blacklist_function`` defines a custom function that blacklist log tags from logging
        their messages. There is no whitelist option. The function must return a boolean where
        ``False`` allows a message to be logged.

        The ``reset`` flag resets the log rules to their initial state when the logger was
        initialized.

        If none of ``min_tag_value``, ``blacklist_tag_names``, ``blacklist_function``, or ``reset`` are
        specified, then no rules are set to restrict any logging. In other words, all logging in the
        specified mode is enabled.

        Args:
            mode: One of 'current', 'console', 'persistence', or 'all'. Default is 'current'. Case sensitive.
            log_tag: The log tag that logs the message concerning the rule change. Defaults to the default tag.
            blacklist_function: Optional. This is evaluate first. If not ``None``, this rule is used.
            min_tag_value: Optional. This is evaluated second. If not ``None``, this rule is used. Can be either
                           the log tag, the log tag value, or any integer.
            blacklist_tag_names: Optional. This is evaluated third. If not ``None``, this rule is used. Must
                                 be a list of tag names.
            reset: Optional. This is evaluated last. If not ``None``, the rule in the specified mode is reset
                   to the initial state.
            why: A description of why the rule is being set.
        """
        if self._disabled:
            return
        log_tag = log_tag or self._log_tags.default
        msg = f'Setting log mode to "{mode}". '
        if blacklist_function:
            msg += f'A function has been defined to decide what is logged.'
        elif min_tag_value:
            if isinstance(min_tag_value, LogTag):
                min_tag_value = min_tag_value.value
            blacklist_function = lambda x: x.value < min_tag_value
            msg += f'Only tag values greater than or equal to {min_tag_value} will be logged.'
        elif blacklist_tag_names:
            blacklist_function = lambda x: x.name in blacklist_tag_names
            msg += f'These tag names are will not be logged: {blacklist_tag_names}'
        elif reset:
            blacklist_function = lambda x: False
            msg += f'Resetting rule. There are no restrictions to logging in this rule.'
        else:
            blacklist_function = lambda x: False
            msg += f'No rule defined. There are no restrictions to logging in this rule.'

        func, file_path, line_num = self._get_log_info(num_prev_callers=0)
        self._commit_log(msg=f'{msg}\n{why}', log_tag=log_tag, file_path=file_path, line_num=line_num, func=func,
                         force=True)

        thread = threading.current_thread()
        if thread.ident == self._main_thread.ident:
            for thread in self._threads.values():
                thread.mode = mode
                thread.blacklist_function = blacklist_function
        elif lt := self._threads.get(thread.ident):
            lt.mode = mode
            lt.blacklist_function = blacklist_function
        else:
            self._threads[thread.ident] = _LogThread(
                depth=self._threads[self._main_thread.ident].depth,
                blacklist_function=blacklist_function,
                mode=mode
            )

    def wrap_class(self, log_tag: LogTag = None, func_regex_exclude: str = '', mask_input_regexes: List = None,
                   mask_output: bool = False):
        """
        Applies ``wrap_func()`` to each callable member of a class that does not start with "__" and does not
        match the ``func_regex_exclude``. If a callable member starting with "--" should be wrapped, then it
        must be explicitly wrapped with the ``wrap_func()`` method. The ``mask_input_regexes`` and ``mask_output``
        parameters are passed to the ``wrap_func()`` method.

        Args:
            log_tag: The log tag to apply to each method wrapped.
            func_regex_exclude: A regular expression matching method names that should not be wrapped.
            mask_input_regexes: Passed to ``wrap_func()``.
            mask_output: Passed to ``wrap_func()``.
        """
        log_tag = log_tag or self._log_tags.default

        def _wrap(cls):
            for attr, fn in inspect.getmembers(cls, inspect.isroutine):
                if callable(getattr(cls, attr)) and not fn.__name__.startswith('__'):
                    if func_regex_exclude:
                        matches = re.findall(pattern=func_regex_exclude, string=fn.__name__, flags=re.IGNORECASE)
                        if fn.__name__ in matches:
                            continue

                    if type(cls.__dict__.get(fn.__name__)) in {staticmethod, classmethod}:
                        setattr(cls, attr, self.wrap_func(
                            log_tag=log_tag,
                            is_static_or_classmethod=True,
                            mask_input_regexes=mask_input_regexes,
                            mask_output=mask_output
                        )(getattr(cls, attr)))
                    else:
                        setattr(cls, attr, self.wrap_func(
                            log_tag=log_tag,
                            mask_input_regexes=mask_input_regexes,
                            mask_output=mask_output
                        )(getattr(cls, attr)))
            return cls

        return _wrap

    def wrap_func(self, log_tag: LogTag = None, mask_input_regexes: List = None, mask_output: bool = False,
                  is_static_or_classmethod: bool = False):
        """
        Wrapper for a function or method. This wrapper performs a few important steps:
            1. Increases the depth of the call, which controls its relation to all previous and
               subsequent logs in the log hierarchy.
            2. Logs the function's name, inputs, and outputs.
            3. Catches any exceptions raised by the function and logs them.
            4. Decreases the depth of the call to return to the original depth of the call hierarchy.

        The depth is the defining marker for the position of a function in the logged hierarchy. Without it,
        all logs would be flat an hard to parse through.

        Example:

            @logger.wrap_func(
                log_tag=Tags.SomeTag,
                mask_input_regexes=['cls', 'password'],
                is_static_or_classmethod=True
            )
            @classmethod
            def do_something(cls, username, password):
                # Do some stuff
                return {'API Token': 'xyz'}

        * The above example will use the ``Tags.SomeTag`` tag to log the calling and the returning message.
        * The ``cls`` and ``password`` parameters will be masked. The message will show this:

          Called do_something
          Arguments:
          {
              "cls": "********",
              "username": "Admin",
              "password": "********"
          }

        * The output is not masked, so the output will show this:

          do_something returned.
          Return Values:
          {
              "API Token": "xyz"
          }

        * If ``is_static_or_classmethod`` was not set to ``True``, then this wrapper would have failed.

        Args:
            log_tag: The log tag to apply to the input and output log messages.
            mask_input_regexes: A list of regular expressions matching input variable names.
            mask_output: When ``True`` the output is masked like this: "********".
            is_static_or_classmethod: Set this to ``True`` when wrapping a staticmethod or classmethod.
        """
        log_tag = log_tag or self._log_tags.default

        def __wrap(func):
            def __wrapper(*args, **kwargs):
                if self._disabled:
                    return func(*args, **kwargs)

                thread = threading.current_thread()
                # If the current thread hasn't been accounted for, account for it by creating a
                # _LogThread using the main thread's configuration.
                if not self._threads.get(thread.ident):
                    mt = self._threads.get(self._main_thread.ident, _LogThread())
                    self._threads[thread.ident] = _LogThread(
                        depth=mt.depth,
                        blacklist_function=mt.blacklist_function,
                        mode=mt.mode
                    )

                # Before the function is called.
                try:
                    if is_static_or_classmethod:
                        # staticmethod and classmethod objects inject another argument at index 0.
                        args = args[1:]
                    params = dict(inspect.signature(func).bind(*args, **kwargs).arguments)  # type: dict
                except TypeError as e:
                    self.log(
                        msg='\n'.join(e.args),
                        log_tag=self._log_tags.critical,
                        num_prev_callers=2
                    )
                    raise TypeError(e)

                before_string = 'Called ' + func.__qualname__
                if params:
                    if mask_input_regexes:
                        in_regexes = "(" + ")|(".join(mask_input_regexes) + ")"
                        for key in params.keys():
                            if re.match(pattern=in_regexes, string=key, flags=re.IGNORECASE):
                                params[key] = '********'
                    before_string += '\nArguments:\n' + self._jsonpickle.dumps(params, max_depth=3, unpicklable=False)
                self.log_method(func=func, msg=before_string, log_tag=log_tag, returning=False)
                self._threads[thread.ident].depth += 1
                try:
                    result = func(*args, **kwargs)

                    # After the function returns.
                    after_string = f'{func.__qualname__} returned.'
                    if result is not None:
                        if mask_output:
                            after_string += ' Output is masked.'
                        else:
                            ret_vals = self._jsonpickle.dumps(result, max_depth=3, unpicklable=False)
                            after_string += f'\nReturn Values: {ret_vals}'
                    self.log_method(func=func, msg=after_string, log_tag=log_tag, returning=True)

                    return result
                except:
                    self.log_exception()
                    raise
                finally:
                    self._threads[thread.ident].depth -= 1

            return __wrapper

        return __wrap

    def _commit_log(self, msg: Any, log_tag: LogTag, file_path: str, line_num: int, func: str = None,
                    force: bool = False):
        """
        Commits the log transaction to the console and the SQLite DB.

        Args:
            msg: Log message.
            log_tag: The log tag that logs the message. Defaults to the default tag.
            file_path: Absolute path to the file in relation to the log message.
            line_num: Line number in the ``file_path``.
            func: Optional. The qualified name of the function in relation to the ``line_num``.
            force: Optional. If ``True``, then the log thread settings are ignored and the message is logged to
                   the console and, if ``persistent logging==True``, then the message is persisted.
        """
        thread = threading.current_thread()
        # If the current thread hasn't been accounted for, account for it by creating a
        # _LogThread using the main thread's configuration.
        if (mt := self._threads.get(thread.ident)) is None:
            mt = self._threads.get(self._main_thread.ident, _LogThread())
            self._threads[thread.ident] = _LogThread(
                depth=mt.depth,
                blacklist_function=mt.blacklist_function,
                mode=mt.mode
            )

        if mt.console_enabled or force:
            print(f'[{log_tag.name}] {self._timestamp()}: {msg}')

        if self._persistent_logging and (mt.persistence_enabled or force):
            with self.logger_lock:
                self._sql.log_entries.insert(
                    file_path=file_path,
                    function_name=func,
                    line_num=line_num,
                    msg=str(msg),
                    tag_name=log_tag.name,
                    depth=self._threads[thread.ident].depth,
                    thread_id=thread.ident,
                    thread_name=thread.name,
                    is_main_thread=(thread.ident == self._main_thread.ident)
                )

    def _get_log_info(self, num_prev_callers: int):
        with self.logger_lock:
            frame = inspect.currentframe()
        # Add two because this should only be called once internally.
        outer_frames = inspect.getouterframes(frame)[num_prev_callers + 2]
        if outer_frames.function == '<module>':
            func = inspect.getmodulename(outer_frames.filename)
        else:
            if (class_name := outer_frames[0].f_locals.get('self')) is not None:
                func = f'{class_name.__class__.__qualname__}.{outer_frames.function}'
            else:
                func = outer_frames.function
        return func, outer_frames[1], outer_frames[2]

    def log(self, msg: Any, log_tag: LogTag = None, num_prev_callers: int = 0):
        """
        Logs a message if logging is enabled and it satisfies the rule set. The default rule allows
        all logging. The rule may specify console logging only, persistent logging only, or both.

        ``num_prev_callers`` allows the caller to specify which caller in the call stack should be
        referenced in the log entry. By default the caller of this method is captured and logged.
        This is handy to use when the caller of this method doesn't live in a method whose context
        is helpful to debugging. For example, if A calls B and B calls ``log(..., prev_num_callers=1)``
        then the message is logged with a reference to the file path, line number, and function name
        from caller A in relation to the call stack.

        Args:
            msg: Log message.
            log_tag: The log tag that logs the message. Defaults to the default tag.
            num_prev_callers: Number of callers in the current call stack previous to the caller of
                              this method.
        """
        if self._disabled:
            return
        log_tag = log_tag or self._log_tags.default
        thread = threading.current_thread()
        if lt := self._threads.get(thread.ident):
            # Check the log rule. If it doesn't pass, return.
            if isinstance(lt.blacklist_function, Callable) and lt.blacklist_function(log_tag) is True:
                return

        func, file_path, line_num = self._get_log_info(num_prev_callers=num_prev_callers)
        self._commit_log(msg=msg, log_tag=log_tag, file_path=file_path, line_num=line_num, func=func)

    def log_exception(self):
        """
        Logs the function name, file path, line number, and exception message if logging is enabled. The
        log tag used is ``critical``. The log rule is disregarded.
        """
        if self._disabled:
            return
        with self.logger_lock:
            tb = sys.exc_info()[2]

        while True:
            if tb.tb_next is None:
                break
            tb = tb.tb_next
        frame = tb.tb_frame
        outer_frames = inspect.getouterframes(frame)[0]
        if outer_frames.function == '<module>':
            func = inspect.getmodulename(outer_frames.filename)
        else:
            if (class_name := outer_frames[0].f_locals.get('self')) is not None:
                func = f'{class_name.__class__.__qualname__}.{outer_frames.function}'
            else:
                func = outer_frames.function
        msg = traceback.format_exc()
        self._commit_log(msg=msg, log_tag=self._log_tags.critical, file_path=outer_frames[1], line_num=outer_frames[2],
                         func=func)

    def log_method(self, func: callable, msg: Any, log_tag: LogTag = None, returning: bool = False):
        """
        Similar to ``log()`` with one particular difference. ``log()`` dynamically retrieves the
        information of the referenced caller using ``num_prev_callers`` whereas ``log_method()``
        directly uses ``func`` for the name of the function and ``returning`` to decide if the
        line number referenced is the first or last line of the function. This method should rarely
        be used. To log a method use ``wrap_func()`` instead as it handles log depth.

        Args:
            func: A callable function.
            msg: Log message.
            log_tag: The log tag that logs the message. Defaults to the default tag.
            returning: If ``True``, reference the last line of the method, otherwise the first.
        """
        if self._disabled:
            return
        log_tag = log_tag or self._log_tags.default
        thread = threading.current_thread()
        if lt := self._threads.get(thread.ident):
            # Check the log rule. If it doesn't pass, return.
            if isinstance(lt.blacklist_function, Callable) and lt.blacklist_function(log_tag) is True:
                return

        self._commit_log(
            msg=msg,
            log_tag=log_tag,
            file_path=func.__code__.co_filename,
            line_num=list(dis.findlinestarts(func.__code__))[-1][-1] if returning else func.__code__.co_firstlineno,
            func=func.__qualname__
        )
