from __future__ import unicode_literals, print_function, absolute_import

import linecache
import os
import sys
import time
import trace
from itertools import chain
from types import ModuleType
from typing import Text, Sequence, Union

from pathlib2 import Path

import six

try:
    from functools import lru_cache
except ImportError:
    from functools32 import lru_cache


def inclusive_parents(path):
    """
    Return path parents including path itself.
    """
    return chain((path,), path.parents)


def get_module_path(module):
    """
    :param module: Module object or name
    :return: module path
    """
    if isinstance(module, six.string_types):
        module = sys.modules[module]
    path = Path(module.__file__)
    return path.parent if path.stem == '__init__' else path


Module = Union[ModuleType, Text]


class PackageTraceIgnore(object):

    """
    Object that includes package modules in trace and excludes sub modules and all other code.
    """

    def __init__(self, package, ignore_submodules):
        # type: (Module, Sequence[Module]) -> None
        """
        Modules given by name will be searched for in sys.modules, enabling use of "__name__".
        :param package: Package to include modules of
        :param ignore_submodules: sub modules of package to ignore
        """
        self.ignore_submodules = tuple(map(get_module_path, ignore_submodules))
        self.package = package
        self.package_path = get_module_path(package)

    @lru_cache(None)
    def names(self, file_name, module_name=None):
        # type: (Text, Text) -> bool
        """
        Return whether a file should be ignored based on it's path and module name.
        Ignore files which are not part of self.package.
        trace.Ignore's documentation states that module_name is unreliable for packages,
        therefore, it is not used here.

        :param file_name: source file path
        :param module_name: module name
        :return: whether file should be ignored
        """
        file_path = Path(file_name).resolve()
        include = self.include(file_path)
        return not include

    def include(self, base):
        # type: (Path) -> bool
        for path in inclusive_parents(base):
            if not path.exists():
                continue
            if any(path.samefile(sub) for sub in self.ignore_submodules):
                return False
            if path.samefile(self.package_path):
                return True
        return False


class PackageTrace(trace.Trace, object):

    """
    Trace object for tracing only lines from a specific package.
    Some functions are copied and modified for lack of modularity of ``trace.Trace``.
    """

    def __init__(self, package, out_file, ignore_submodules=(), *args, **kwargs):
        super(PackageTrace, self).__init__(*args, **kwargs)
        self.ignore = PackageTraceIgnore(package, ignore_submodules)
        self.__out_file = out_file

    def __out(self, *args, **kwargs):
        print(*args, file=self.__out_file, **kwargs)

    def globaltrace_lt(self, frame, why, arg):
        """
        ## Copied from trace module ##
        Handler for call events.
        If the code block being entered is to be ignored, returns `None',
        else returns self.localtrace.
        """
        if why == 'call':
            code = frame.f_code
            filename = frame.f_globals.get('__file__', None)
            if filename:
                # XXX modname() doesn't work right for packages, so
                # the ignore support won't work right for packages
                ignore_it = self.ignore.names(filename)
                if not ignore_it:
                    if self.trace:
                        filename = Path(filename)
                        modulename = '.'.join(
                            filename.relative_to(self.ignore.package_path).parts[:-1] + (filename.stem,)
                        )
                        self.__out(' --- modulename: %s, funcname: %s' % (modulename, code.co_name))
                    return self.localtrace
            else:
                return None

    def localtrace_trace(self, frame, why, arg):
        """
        ## Copied from trace module ##
        """
        if why == "line":
            # record the file name and line number of every trace
            filename = frame.f_code.co_filename
            lineno = frame.f_lineno

            if self.start_time:
                self.__out('%.2f' % (time.time() - self.start_time), end='')
            bname = os.path.basename(filename)
            self.__out('%s(%d): %s' % (bname, lineno, linecache.getline(filename, lineno)), end='')
        return self.localtrace

    localtrace_trace_and_count = localtrace_trace