import os
import sys
import re
import py
import pytest
import logging
import time
import datetime
import argparse
from builtins import object, int
from past.builtins import basestring


def pytest_addhooks(pluginmanager):
    pluginmanager.add_hookspecs(LoggerHookspec)


def _late_addoptions(parser, logcfg):
    """Add options to control logger"""
    parser.addini(
        name='logger_logsdir',
        help='base directory with log files for file loggers [basetemp]',
        default=None,
    )
    group = parser.getgroup('logger')
    group.addoption('--logger-logsdir',
                    help='pick you own logs directory instead of default '
                         'directory under session tmpdir')

    if logcfg._enabled:
        parser = _log_option_parser(logcfg._loggers)
        group.addoption('--loggers',
                        default=parser(logcfg._log_option_default),
                        type=parser,
                        metavar='LOGGER,LOGGER.LEVEL,...',
                        help='comma delimited list of loggers optionally suffixed with level '
                             'preceded by a dot. Levels can be lower or uppercase, or numeric. '
                             'For example: "logger1,logger2.info,logger3.FATAL,logger4.25"')


@pytest.hookimpl(trylast=True)
def pytest_load_initial_conftests(early_config, parser, args):
    logcfg = LoggerConfig()
    early_config.hook.pytest_logger_config(logger_config=logcfg)
    early_config.pluginmanager.register(EarlyLoggerPlugin(logcfg), '_early_logger')
    _late_addoptions(parser, logcfg)


def pytest_configure(config):
    early_logger = config.pluginmanager.getplugin('_early_logger')
    config.pluginmanager.register(LoggerPlugin(config, early_logger._logcfg), '_logger')


class EarlyLoggerPlugin(object):
    def __init__(self, logcfg):
        self._logcfg = logcfg


class LoggerPlugin(object):
    def __init__(self, config, logcfg):
        self._config = config
        self._logdirlinks = config.hook.pytest_logger_logdirlink(config=config)
        self._loggers = _loggers_from_logcfg(logcfg, config.getoption('loggers')) if logcfg._enabled else None
        self._formatter_class = logcfg._formatter_class or DefaultFormatter
        self._logsdir = None
        self._split_by_outcome_subdir = logcfg._split_by_outcome_subdir
        self._split_by_outcome_outcomes = logcfg._split_by_outcome_outcomes

    def logsdir(self):
        ldir = self._logsdir
        if ldir:
            return ldir
        logger_logsdir = self._config.getoption('logger_logsdir')
        if not logger_logsdir:
            logger_logsdir = self._config.getini('logger_logsdir')
        if not logger_logsdir:
            logger_logsdir = self._config.hook.pytest_logger_logsdir(config=self._config)
        if logger_logsdir:
            ldir = _make_logsdir_dir(logger_logsdir)
        else:
            ldir = _make_logsdir_tmpdir(self._config._tmpdirhandler)

        self._logsdir = ldir

        for link in self._logdirlinks:
            _refresh_link(str(ldir), link)

        return ldir

    def pytest_runtest_setup(self, item):
        loggers = _choose_loggers(self._loggers, _loggers_from_hooks(item))
        formatter = self._formatter_class()
        item._logger = state = LoggerState(item=item,
                                           stdoutloggers=loggers.stdout,
                                           fileloggers=loggers.file,
                                           formatter=formatter)
        state.on_setup()

    def pytest_runtest_teardown(self, item, nextitem):
        logger = getattr(item, '_logger', None)
        if logger:
            logger.on_teardown()

    @pytest.mark.hookwrapper
    def pytest_runtest_makereport(self, item, call):
        outcome = yield
        tr = outcome.get_result()
        logger = getattr(item, '_logger', None)
        if logger:
            if self._logsdir and self._split_by_outcome_subdir and tr.outcome in self._split_by_outcome_outcomes:
                split_by_outcome_logdir = self._logsdir.join(self._split_by_outcome_subdir, tr.outcome)
                nodeid = _sanitize_nodeid(item.nodeid)
                nodepath = os.path.dirname(nodeid)
                split_by_outcome_logdir.join(nodepath).ensure(dir=1)
                destdir_relpath = os.path.relpath(str(self._logsdir.join(nodeid)),
                                                  str(split_by_outcome_logdir.join(nodepath)))
                _refresh_link(destdir_relpath, str(split_by_outcome_logdir.join(nodeid)))
            if call.when == 'teardown':
                logger.on_makereport()


class LoggerState(object):
    def __init__(self, item, stdoutloggers, fileloggers, formatter):
        self._put_newlines = bool(item.config.option.capture == 'no' and stdoutloggers)
        self.handlers = _make_handlers(stdoutloggers, fileloggers, item, formatter)
        self.root_enabler = RootEnabler(bool(stdoutloggers and fileloggers))

    def put_newline(self):
        if self._put_newlines:
            sys.stdout.write('\n')

    def on_setup(self):
        self.put_newline()
        _enable(self.handlers)
        self.root_enabler.enable()

    def on_teardown(self):
        self.put_newline()

    def on_makereport(self):
        self.root_enabler.disable()
        _disable(self.handlers)


class RootEnabler(object):
    def __init__(self, enabled):
        self._enabled = enabled
        self._root_level = logging.root.level

    def enable(self):
        if self._enabled:
            self._root_level = logging.root.level
            logging.root.setLevel(logging.NOTSET)  # stops root logger from blocking logs

    def disable(self):
        if self._enabled:
            logging.root.setLevel(self._root_level)


class Loggers(object):
    def __init__(self, stdout, file_):
        self.stdout = stdout
        self.file = file_

    def __bool__(self):
        return bool(self.stdout) or bool(self.file)


class LoggerConfig(object):
    """Configuration of logging to stdout and filesystem."""

    def __init__(self):
        self._enabled = False
        self._loggers = []
        self._formatter_class = None
        self._log_option_default = ''
        self._split_by_outcome_subdir = None
        self._split_by_outcome_outcomes = []

    def add_loggers(self, loggers, stdout_level=logging.NOTSET, file_level=logging.NOTSET):
        """Adds loggers for stdout/filesystem handling.

        Stdout: loggers will log to stdout only when mentioned in `loggers` option. If they're
        mentioned without explicit level, `stdout_level` will be used.

        Filesystem: loggers will log to files at `file_level`.

        :arg loggers: List of logger names.

        :arg stdout_level: Default level at which stdout handlers will pass logs.
           By default: `logging.NOTSET`, which means: pass everything.

        :arg file_level: Level at which filesystem handlers will pass logs.
           By default: `logging.NOTSET`, which means: pass everything.
        """
        self._enabled = True
        self._loggers.append((loggers, _sanitize_level(stdout_level), _sanitize_level(file_level)))

    def set_formatter_class(self, formatter_class):
        """Sets the `logging.Formatter` class to be used by all loggers.

        :arg formatter_class: The `logging.Formatter` class
        """
        if isinstance(formatter_class, logging.Formatter):
            raise ValueError("Got a formatter instance instead of its class !")

        if not issubclass(formatter_class, logging.Formatter):
            raise ValueError("Formatter should be a class inheriting from logging.Formatter")
        self._formatter_class = formatter_class

    def set_log_option_default(self, value):
        """ Sets default value of `log` option."""
        self._log_option_default = value

    def split_by_outcome(self, outcomes=None, subdir='by_outcome'):
        """Makes a directory inside main logdir where logs are further split by test outcome

        :param outcomes: list of test outcomes to be handled (failed/passed/skipped)
        :param subdir: name for the subdirectory in main log directory
        """
        if outcomes is not None:
            allowed_outcomes = ['passed', 'failed', 'skipped']
            unexpected_outcomes = set(outcomes) - set(allowed_outcomes)
            if unexpected_outcomes:
                raise ValueError('got unexpected_outcomes: <' + str(list(unexpected_outcomes)) + '>')
            self._split_by_outcome_outcomes = outcomes
        else:
            self._split_by_outcome_outcomes = ['failed']

        self._split_by_outcome_subdir = subdir


class LoggerHookspec(object):
    def pytest_logger_config(self, logger_config):
        """ called before cmdline options parsing. Accepts terse configuration
        of both stdout and file logging, adds cmdline options to manipulate
        stdout logging. Cannot be used together with \\*loggers hooks.

        :arg logger_config: instance of :py:class:`LoggerConfig`, allows
           setting loggers for stdout and file handling and their levels.
        """

    def pytest_logger_stdoutloggers(self, item):
        """ called before testcase setup. If implemented, given loggers
        will emit their output to terminal output. Cannot be used together with
        logger_config hook.

        :arg item: test item for which handlers are to be setup.

        :return list: List should contain logger name strings
                      or tuples with logger name string and logging level.
        """

    def pytest_logger_fileloggers(self, item):
        """ called before testcase setup. If implemented, given loggers
        will emit their output to files within logs temporary directory.
        Cannot be used together with logger_config hook.

        :arg item: test item for which handlers are to be setup.

        :return list: List should contain logger name strings
                      or tuples with logger name string and logging level.
        """

    def pytest_logger_logdirlink(self, config):
        """ called after cmdline options parsing.
        If implemented, symlink to logs directory will be created.

        :arg config: pytest config object, holds e.g. options

        :return string: Absolute path of requested link to logs directory.
        """

    @pytest.hookspec(firstresult=True)
    def pytest_logger_logsdir(self, config):
        """ called after cmdline options parsing.
        If implemented, place logs into the location returned. This is similar
        to using --logger-logsdir or the logger_logsdir ini option, but will
        only be used if those are not.

        Additionally, if multiple implementations of this hook are found, only
        the first non-None value will be used.

        :arg config: pytest config object, holds e.g. options

        :return string: Absolute path of logs directory.
        """


class DefaultFormatter(logging.Formatter):
    short_level_names = {
        logging.FATAL: 'ftl',
        logging.ERROR: 'err',
        logging.WARN: 'wrn',
        logging.INFO: 'inf',
        logging.DEBUG: 'dbg',
    }

    format_string = '%(asctime)s %(levelshortname)s %(name)s: %(message)s'

    def __init__(self):
        logging.Formatter.__init__(self, DefaultFormatter.format_string)
        self._start = time.time()

    def formatTime(self, record, datefmt=None):
        ct = record.created - self._start
        dt = datetime.datetime.utcfromtimestamp(ct)
        return dt.strftime("%M:%S.%f")[:-3]  # omit useconds, leave mseconds

    def format(self, record):
        record.levelshortname = DefaultFormatter.short_level_names.get(record.levelno,
                                                                       'l%s' % record.levelno)
        return logging.Formatter.format(self, record)


@pytest.fixture
def logdir(request):
    return _make_logdir(request._pyfuncitem)


def _sanitize_nodeid(filename):
    filename = filename.replace('::()::', '/')
    filename = filename.replace('::', '/')
    filename = re.sub(r'\[(.+)\]', r'-\1', filename)
    return filename


def _sanitize_level(level, raises=True):
    if isinstance(level, basestring):
        try:
            return int(level)
        except ValueError:
            int_level = getattr(logging, level.upper(), None)
            if int_level is not None:
                return int_level
    elif isinstance(level, int):
        return level
    if raises:
        raise TypeError('bad logging level, expected int or string, got "%s"' % level)


def _refresh_link(source, link_name):
    try:
        os.unlink(link_name)
    except OSError:
        pass
    try:
        os.symlink(source, link_name)
    except (OSError, AttributeError, NotImplementedError):
        pass


def _make_logsdir_tmpdir(tmpdirhandler):
    logsdir = tmpdirhandler.getbasetemp()
    if logsdir.basename.startswith('popen-gw'):
        logsdir = logsdir.join('..')
    logsdir = logsdir.join('logs').ensure(dir=1)
    return logsdir


def _make_logsdir_dir(dstname, cleandir=True):
    logsdir = py.path.local(dstname)
    if cleandir:
        if logsdir.check():
            logsdir.remove()
        logsdir.mkdir()
    return logsdir


def _make_logdir(item):
    plugin = item.config.pluginmanager.getplugin('_logger')
    return plugin.logsdir().join(_sanitize_nodeid(item.nodeid)).ensure(dir=1)


def _enable(handlers):
    for hdlr in handlers:
        hdlr.logger.addHandler(hdlr)


def _disable(handlers):
    for hdlr in handlers:
        hdlr.logger.removeHandler(hdlr)
        hdlr.close()


def _log_option_parser(loggers):
    def parser(arg):
        def to_out(elem):
            def find_row(name):
                return next((row for row in loggers if name in row[0]), None)

            def bad_logger(name):
                names = [x for row in loggers for x in row[0]]
                pretty_names = '(' + ', '.join(names) + ')'
                raise argparse.ArgumentTypeError(
                    'wrong logger, expected %s, got "%s"' % (pretty_names, name))

            def bad_level(level):
                raise argparse.ArgumentTypeError(
                    'wrong level, expected (INFO, warn, 15, ...), got "%s"' % level)

            row = find_row(elem)
            if row:
                return elem, row[1]
            if '.' in elem:
                elem_name, elem_level = elem.rsplit('.', 1)
                row = find_row(elem_name)
                level = _sanitize_level(elem_level, raises=False)
                if row and level is not None:
                    return elem_name, level
                if row:
                    bad_level(elem_level)
                if level is not None:
                    bad_logger(elem_name)
            bad_logger(elem)
        return [to_out(x) for x in arg.split(',') if x]
    return parser


def _loggers_from_logcfg(logcfg, logopt):
    def to_stdout(loggers, opt):
        def one(loggers, one):
            if isinstance(one, basestring):
                return one, next(row for row in loggers if one in row[0])[1]
            else:
                return one
        return [one(loggers, x) for x in opt]

    def to_file(loggers):
        return [(name, row[2]) for row in loggers for name in row[0]]

    return Loggers(
        stdout=to_stdout(logcfg._loggers, logopt),
        file_=to_file(logcfg._loggers)
    )


def _loggers_from_hooks(item):
    def to_loggers(configs_lists):
        def to_logger_and_level(cfg):
            if isinstance(cfg, basestring):
                name, level = cfg, logging.NOTSET
            else:
                name, level = cfg
            return name, level
        return [to_logger_and_level(cfg) for configs in configs_lists for cfg in configs]
    return Loggers(
        stdout=to_loggers(item.config.hook.pytest_logger_stdoutloggers(item=item)),
        file_=to_loggers(item.config.hook.pytest_logger_fileloggers(item=item))
    )


def _choose_loggers(config_loggers, hook_loggers):
    assert (not config_loggers) or (not hook_loggers),\
        'pytest_logger_config and pytest_logger_*loggers hooks used at the same time'
    return config_loggers or hook_loggers


def _make_handlers(stdoutloggers, fileloggers, item, formatter):
    handlers = []
    if stdoutloggers:
        handlers += _make_stdout_handlers(stdoutloggers, formatter)
    if fileloggers:
        logdir = _make_logdir(item)
        handlers += _make_file_handlers(fileloggers, formatter, logdir)
    return handlers


def _make_stdout_handlers(loggers, fmt):
    def make_handler(logger_and_level, fmt):
        name, level = logger_and_level
        logger = logging.getLogger(name)
        handler = logging.StreamHandler(sys.stdout)
        handler.setFormatter(fmt)
        handler.setLevel(level)
        handler.logger = logger
        return handler

    return [make_handler(lgr, fmt) for lgr in loggers]


def _make_file_handlers(loggers, fmt, logdir):
    def make_handler(logdir, logger_and_level, fmt):
        name, level = logger_and_level
        logger = logging.getLogger(name)
        name = name or 'logs'
        logfile = str(logdir.join(name))
        handler = logging.FileHandler(filename=logfile, mode='w', delay=True)
        handler.setFormatter(fmt)
        handler.setLevel(level)
        handler.logger = logger
        return handler

    return [make_handler(logdir, lgr, fmt) for lgr in loggers]