import inspect
import gc
import sys
import os.path
import difflib
from collections import OrderedDict

import pandas as pd
from pandas.core.common import in_ipnb

def is_property(code):
    """
    Using some CPython gc magics, check if a code object is a property

    gc idea taken from trace.py from stdlib
    """
    ## use of gc.get_referrers() was suggested by Michael Hudson
    # all functions which refer to this code object
    gc.collect()
    code_refs = gc.get_referrers(code)
    funcs = [f for f in code_refs
                    if inspect.isfunction(f)]
    if len(funcs) != 1:
        return False

    # property object will reference the original func
    props = [p for p in gc.get_referrers(funcs[0])
                    if isinstance(p, property)]
    return len(props) == 1

def is_class_dict(dct):
    if not isinstance(dct, dict):
        return False
    if '__dict__' not in dct or not inspect.isgetsetdescriptor(dct['__dict__']):
        return False
    return True

def get_parent(code):
    funcs = [f for f in gc.get_referrers(code)
                    if inspect.isfunction(f)]

    if len(funcs) != 1:
        return None

    refs = [f for f in gc.get_referrers(funcs[0])]

    for ref in refs:
        # assume if that if a dict is pointed to by a class,
        # that dict is the __dict__
        if isinstance(ref, dict):
            parents = [p for p in gc.get_referrers(ref) if isinstance(p, type)]
            if len(parents) == 1:
                return parents[0].__name__

        if inspect.ismethod(ref):
            return ref.__qualname__.rsplit('.', 1)[0]

    return None

class Follow(object):
    """
    Follows execution path.

    Meant as a quick way to see what a function does.

    In [2]: with Follow() as f:
    ...:     df.sum()
    ...:

    In [3]: f.pprint(depth=2)
    stat_func generic.py:3542
        _reduce frame.py:3995
            _get_axis_number generic.py:285
            _get_agg_axis frame.py:4128
            as_matrix generic.py:1938
    """
    def __init__(self, depth=1, silent=False, parent=False):
        self.depth = depth
        self.silent = silent
        self.timings = []
        self.frame_cache = {}
        self._caller_cache = {}
        self.parent = parent

        self.stack_depth_cache = {}

    def current_depth(self, frame):
        current_depth = None
        i = 0
        f = frame.f_back
        while f:
            i += 1
            parent_depth = self.stack_depth_cache.get(id(f), None)
            if parent_depth is not None:
                current_depth = i + parent_depth
                break
            # if we're already past depth, don't bother finding real depth
            if i > self.depth:
                return None
            f = f.f_back

        # should always at least get back to base parent
        return current_depth

    def trace_dispatch(self, frame, event, arg):
        if len(self.stack_depth_cache) == 0:
            # __enter__ is the intial frame
            self.stack_depth_cache[id(frame.f_back)] = 0

        # the lower parts get heavy. don't do anything or frames deeper
        # than depth
        current_depth = self.current_depth(frame)
        if current_depth is None:
            return

        if current_depth > self.depth:
            return

        if event not in ['call', 'c_call']:
            return

        # skip built in funcs
        if inspect.isbuiltin(arg):
            return

        # skip properties, we're only really interested in function calls
        # this will unfortunently skip any important logic that is wrapped
        # in property logic
        code = frame.f_code
        if is_property(code):
            return

        # note that get_parent is supa slow.
        parent_name = None
        if self.parent:
            parent_name = get_parent(code)

        indent, first_parent = self.indent_level(frame)
        f = frame.f_back
        if event == "c_call":
            func_name = arg.__name__
            fn = (indent, "", 0, func_name, id(frame),id(first_parent), None)
        elif event == 'call':
            fcode = frame.f_code
            fn = (indent, fcode.co_filename, fcode.co_firstlineno,
                  fcode.co_name, id(frame), id(first_parent), parent_name)

        self.timings.append(fn)

    def indent_level(self, frame):
        i = 0
        f = frame.f_back
        first_parent = f
        while f:
            if id(f) in self.frame_cache:
                i += 1
            f = f.f_back
        if i == 0:
            # clear out the frame cache
            self.frame_cache = {id(frame): True}
        else:
            self.frame_cache[id(frame)] = True
        return i, first_parent

    def to_frame(self):
        data = self.timings
        cols = ['indent', 'filename', 'lineno', 'func_name', 'frame_id',
                'parent_id', 'parent_name']
        df = pd.DataFrame(data, columns=cols)
        df.loc[:, 'filename'] = df.filename.apply(lambda s: os.path.basename(s))
        return df

    def __enter__(self):
        sys.setprofile(self.trace_dispatch)
        return self

    def __exit__(self, type, value, traceback):
        sys.setprofile(None)
        if not self.silent:
            self.pprint(self.depth)

    def file_module_function_of(self, frame):
        code = frame.f_code
        filename = code.co_filename
        if filename:
            modulename = modname(filename)
        else:
            modulename = None

        funcname = code.co_name
        clsname = None
        if code in self._caller_cache:
            if self._caller_cache[code] is not None:
                clsname = self._caller_cache[code]
        else:
            self._caller_cache[code] = None
            ## use of gc.get_referrers() was suggested by Michael Hudson
            # all functions which refer to this code object
            funcs = [f for f in gc.get_referrers(code)
                         if inspect.isfunction(f)]
            # require len(func) == 1 to avoid ambiguity caused by calls to
            # new.function(): "In the face of ambiguity, refuse the
            # temptation to guess."
            if len(funcs) == 1:
                dicts = [d for d in gc.get_referrers(funcs[0])
                             if isinstance(d, dict)]
                if len(dicts) == 1:
                    classes = [c for c in gc.get_referrers(dicts[0])
                                   if hasattr(c, "__bases__")]
                    if len(classes) == 1:
                        # ditto for new.classobj()
                        clsname = classes[0].__name__
                        # cache the result - assumption is that new.* is
                        # not called later to disturb this relationship
                        # _caller_cache could be flushed if functions in
                        # the new module get called.
                        self._caller_cache[code] = clsname
        if clsname is not None:
            funcname = "%s.%s" % (clsname, funcname)

        return filename, modulename, funcname

    def gen_output(self, depth=None):
        df = self.to_frame()
        mask = df.filename == ''
        mask = mask | df.func_name.isin(['<lambda>', '<genexpr>'])
        mask = mask | df.func_name.str.startswith('__')
        if depth:
            mask = mask | (df.indent > depth)

        MSG_FORMAT = "{indent}{func_name}{class_name} <{filename}:{lineno}>"

        df = df.loc[~mask]
        def format(row):
            indent = row[0]
            filename = row[1]
            lineno = row[2]
            func_name = row[3]
            class_name = row[6] or ''
            if class_name:
                class_name = '::{class_name}'.format(class_name=class_name)

            msg = MSG_FORMAT.format(indent=" "*indent*4, func_name=func_name,
                                    filename=filename, lineno=lineno,
                                    class_name=class_name)
            return msg

        df = df.reset_index(drop=True)

        output = df.apply(format, axis=1, raw=True)

        return output.tolist()

    def pprint(self, depth=None):
        output = self.gen_output(depth=depth)
        print(("-" * 40))
        print(("Follow Path (depth {depth}):".format(depth=depth)))
        print(("-" * 40))
        print(("\n".join(output)))

    def diff(self, right, depth):
        if in_ipnb():
            return self._html_diff(right=right, depth=depth)
        else:
            return self._text_diff(right=right, depth=depth)

    def _text_diff(self, right, depth):
        output = self.gen_output(depth)
        output2 = right.gen_output(depth)

        htmldiff = difflib.HtmlDiff()
        return '\n'.join(difflib.ndiff(output, output2))

    def _html_diff(self, right, depth):
        from IPython.core.display import HTML
        output = self.gen_output(depth)
        output2 = right.gen_output(depth)

        htmldiff = difflib.HtmlDiff()
        diff = htmldiff.make_table(output, output2)
        return HTML(diff)
