import gc
import inspect
import sys
import time

try:
    import objgraph
except ImportError:
    objgraph = None

import cherrypy
from cherrypy import _cprequest, _cpwsgi
from cherrypy.process.plugins import SimplePlugin


class ReferrerTree(object):

    """An object which gathers all referrers of an object to a given depth."""

    peek_length = 40

    def __init__(self, ignore=None, maxdepth=2, maxparents=10):
        self.ignore = ignore or []
        self.ignore.append(inspect.currentframe().f_back)
        self.maxdepth = maxdepth
        self.maxparents = maxparents

    def ascend(self, obj, depth=1):
        """Return a nested list containing referrers of the given object."""
        depth += 1
        parents = []

        # Gather all referrers in one step to minimize
        # cascading references due to repr() logic.
        refs = gc.get_referrers(obj)
        self.ignore.append(refs)
        if len(refs) > self.maxparents:
            return [('[%s referrers]' % len(refs), [])]

        try:
            ascendcode = self.ascend.__code__
        except AttributeError:
            ascendcode = self.ascend.im_func.func_code
        for parent in refs:
            if inspect.isframe(parent) and parent.f_code is ascendcode:
                continue
            if parent in self.ignore:
                continue
            if depth <= self.maxdepth:
                parents.append((parent, self.ascend(parent, depth)))
            else:
                parents.append((parent, []))

        return parents

    def peek(self, s):
        """Return s, restricted to a sane length."""
        if len(s) > (self.peek_length + 3):
            half = self.peek_length // 2
            return s[:half] + '...' + s[-half:]
        else:
            return s

    def _format(self, obj, descend=True):
        """Return a string representation of a single object."""
        if inspect.isframe(obj):
            filename, lineno, func, context, index = inspect.getframeinfo(obj)
            return "<frame of function '%s'>" % func

        if not descend:
            return self.peek(repr(obj))

        if isinstance(obj, dict):
            return '{' + ', '.join(['%s: %s' % (self._format(k, descend=False),
                                                self._format(v, descend=False))
                                    for k, v in obj.items()]) + '}'
        elif isinstance(obj, list):
            return '[' + ', '.join([self._format(item, descend=False)
                                    for item in obj]) + ']'
        elif isinstance(obj, tuple):
            return '(' + ', '.join([self._format(item, descend=False)
                                    for item in obj]) + ')'

        r = self.peek(repr(obj))
        if isinstance(obj, (str, int, float)):
            return r
        return '%s: %s' % (type(obj), r)

    def format(self, tree):
        """Return a list of string reprs from a nested list of referrers."""
        output = []

        def ascend(branch, depth=1):
            for parent, grandparents in branch:
                output.append(('    ' * depth) + self._format(parent))
                if grandparents:
                    ascend(grandparents, depth + 1)
        ascend(tree)
        return output


def get_instances(cls):
    return [x for x in gc.get_objects() if isinstance(x, cls)]


class RequestCounter(SimplePlugin):

    def start(self):
        self.count = 0

    def before_request(self):
        self.count += 1

    def after_request(self):
        self.count -= 1
request_counter = RequestCounter(cherrypy.engine)  # noqa: E305
request_counter.subscribe()


def get_context(obj):
    if isinstance(obj, _cprequest.Request):
        return 'path=%s;stage=%s' % (obj.path_info, obj.stage)
    elif isinstance(obj, _cprequest.Response):
        return 'status=%s' % obj.status
    elif isinstance(obj, _cpwsgi.AppResponse):
        return 'PATH_INFO=%s' % obj.environ.get('PATH_INFO', '')
    elif hasattr(obj, 'tb_lineno'):
        return 'tb_lineno=%s' % obj.tb_lineno
    return ''


class GCRoot(object):

    """A CherryPy page handler for testing reference leaks."""

    classes = [
        (_cprequest.Request, 2, 2,
         'Should be 1 in this request thread and 1 in the main thread.'),
        (_cprequest.Response, 2, 2,
         'Should be 1 in this request thread and 1 in the main thread.'),
        (_cpwsgi.AppResponse, 1, 1,
         'Should be 1 in this request thread only.'),
    ]

    @cherrypy.expose
    def index(self):
        return 'Hello, world!'

    @cherrypy.expose
    def stats(self):
        output = ['Statistics:']

        for trial in range(10):
            if request_counter.count > 0:
                break
            time.sleep(0.5)
        else:
            output.append('\nNot all requests closed properly.')

        # gc_collect isn't perfectly synchronous, because it may
        # break reference cycles that then take time to fully
        # finalize. Call it thrice and hope for the best.
        gc.collect()
        gc.collect()
        unreachable = gc.collect()
        if unreachable:
            if objgraph is not None:
                final = objgraph.by_type('Nondestructible')
                if final:
                    objgraph.show_backrefs(final, filename='finalizers.png')

            trash = {}
            for x in gc.garbage:
                trash[type(x)] = trash.get(type(x), 0) + 1
            if trash:
                output.insert(0, '\n%s unreachable objects:' % unreachable)
                trash = [(v, k) for k, v in trash.items()]
                trash.sort()
                for pair in trash:
                    output.append('    ' + repr(pair))

        # Check declared classes to verify uncollected instances.
        # These don't have to be part of a cycle; they can be
        # any objects that have unanticipated referrers that keep
        # them from being collected.
        allobjs = {}
        for cls, minobj, maxobj, msg in self.classes:
            allobjs[cls] = get_instances(cls)

        for cls, minobj, maxobj, msg in self.classes:
            objs = allobjs[cls]
            lenobj = len(objs)
            if lenobj < minobj or lenobj > maxobj:
                if minobj == maxobj:
                    output.append(
                        '\nExpected %s %r references, got %s.' %
                        (minobj, cls, lenobj))
                else:
                    output.append(
                        '\nExpected %s to %s %r references, got %s.' %
                        (minobj, maxobj, cls, lenobj))

                for obj in objs:
                    if objgraph is not None:
                        ig = [id(objs), id(inspect.currentframe())]
                        fname = 'graph_%s_%s.png' % (cls.__name__, id(obj))
                        objgraph.show_backrefs(
                            obj, extra_ignore=ig, max_depth=4, too_many=20,
                            filename=fname, extra_info=get_context)
                    output.append('\nReferrers for %s (refcount=%s):' %
                                  (repr(obj), sys.getrefcount(obj)))
                    t = ReferrerTree(ignore=[objs], maxdepth=3)
                    tree = t.ascend(obj)
                    output.extend(t.format(tree))

        return '\n'.join(output)
