# -*- coding: utf-8 -*-
"""
    sphinx.ext.coverage
    ~~~~~~~~~~~~~~~~~~~

    Check Python modules and C API for coverage.  Mostly written by Josip
    Dzolonga for the Google Highly Open Participation contest.

    :copyright: Copyright 2007-2014 by the Sphinx team, see AUTHORS.
    :license: BSD, see LICENSE for details.
"""

import re
import glob
import inspect
import pickle as pickle
from os import path

from sphinx.builders import Builder


# utility
def write_header(f, text, char='-'):
    f.write(text + '\n')
    f.write(char * len(text) + '\n')

def compile_regex_list(name, exps, warnfunc):
    lst = []
    for exp in exps:
        try:
            lst.append(re.compile(exp))
        except Exception:
            warnfunc('invalid regex %r in %s' % (exp, name))
    return lst


class CoverageBuilder(Builder):

    name = 'coverage'

    def init(self):
        self.c_sourcefiles = []
        for pattern in self.config.coverage_c_path:
            pattern = path.join(self.srcdir, pattern)
            self.c_sourcefiles.extend(glob.glob(pattern))

        self.c_regexes = []
        for (name, exp) in list(self.config.coverage_c_regexes.items()):
            try:
                self.c_regexes.append((name, re.compile(exp)))
            except Exception:
                self.warn('invalid regex %r in coverage_c_regexes' % exp)

        self.c_ignorexps = {}
        for (name, exps) in self.config.coverage_ignore_c_items.items():
            self.c_ignorexps[name] = compile_regex_list(
                'coverage_ignore_c_items', exps, self.warn)
        self.mod_ignorexps = compile_regex_list(
            'coverage_ignore_modules', self.config.coverage_ignore_modules,
            self.warn)
        self.cls_ignorexps = compile_regex_list(
            'coverage_ignore_classes', self.config.coverage_ignore_classes,
            self.warn)
        self.fun_ignorexps = compile_regex_list(
            'coverage_ignore_functions', self.config.coverage_ignore_functions,
            self.warn)

    def get_outdated_docs(self):
        return 'coverage overview'

    def write(self, *ignored):
        self.py_undoc = {}
        self.build_py_coverage()
        self.write_py_coverage()

        self.c_undoc = {}
        self.build_c_coverage()
        self.write_c_coverage()

    def build_c_coverage(self):
        # Fetch all the info from the header files
        c_objects = self.env.domaindata['c']['objects']
        for filename in self.c_sourcefiles:
            undoc = []
            f = open(filename, 'r')
            try:
                for line in f:
                    for key, regex in self.c_regexes:
                        match = regex.match(line)
                        if match:
                            name = match.groups()[0]
                            if name not in c_objects:
                                for exp in self.c_ignorexps.get(key, ()):
                                    if exp.match(name):
                                        break
                                else:
                                    undoc.append((key, name))
                            continue
            finally:
                f.close()
            if undoc:
                self.c_undoc[filename] = undoc

    def write_c_coverage(self):
        output_file = path.join(self.outdir, 'c.txt')
        op = open(output_file, 'w')
        try:
            if self.config.coverage_write_headline:
                write_header(op, 'Undocumented C API elements', '=')
            op.write('\n')

            for filename, undoc in self.c_undoc.items():
                write_header(op, filename)
                for typ, name in undoc:
                    op.write(' * %-50s [%9s]\n' % (name, typ))
                op.write('\n')
        finally:
            op.close()

    def build_py_coverage(self):
        objects = self.env.domaindata['py']['objects']
        modules = self.env.domaindata['py']['modules']

        skip_undoc = self.config.coverage_skip_undoc_in_source

        for mod_name in modules:
            ignore = False
            for exp in self.mod_ignorexps:
                if exp.match(mod_name):
                    ignore = True
                    break
            if ignore:
                continue

            try:
                mod = __import__(mod_name, fromlist=['foo'])
            except ImportError as err:
                self.warn('module %s could not be imported: %s' %
                          (mod_name, err))
                self.py_undoc[mod_name] = {'error': err}
                continue

            funcs = []
            classes = {}

            for name, obj in inspect.getmembers(mod):
                # diverse module attributes are ignored:
                if name[0] == '_':
                    # begins in an underscore
                    continue
                if not hasattr(obj, '__module__'):
                    # cannot be attributed to a module
                    continue
                if obj.__module__ != mod_name:
                    # is not defined in this module
                    continue

                full_name = '%s.%s' % (mod_name, name)

                if inspect.isfunction(obj):
                    if full_name not in objects:
                        for exp in self.fun_ignorexps:
                            if exp.match(name):
                                break
                        else:
                            if skip_undoc and not obj.__doc__:
                                continue
                            funcs.append(name)
                elif inspect.isclass(obj):
                    for exp in self.cls_ignorexps:
                        if exp.match(name):
                            break
                    else:
                        if full_name not in objects:
                            if skip_undoc and not obj.__doc__:
                                continue
                            # not documented at all
                            classes[name] = []
                            continue

                        attrs = []

                        for attr_name in dir(obj):
                            if attr_name not in obj.__dict__:
                                continue
                            attr = getattr(obj, attr_name)
                            if not (inspect.ismethod(attr) or
                                    inspect.isfunction(attr)):
                                continue
                            if attr_name[0] == '_':
                                # starts with an underscore, ignore it
                                continue
                            if skip_undoc and not attr.__doc__:
                                # skip methods without docstring if wished
                                continue

                            full_attr_name = '%s.%s' % (full_name, attr_name)
                            if full_attr_name not in objects:
                                attrs.append(attr_name)

                        if attrs:
                            # some attributes are undocumented
                            classes[name] = attrs

            self.py_undoc[mod_name] = {'funcs': funcs, 'classes': classes}

    def write_py_coverage(self):
        output_file = path.join(self.outdir, 'python.txt')
        op = open(output_file, 'w')
        failed = []
        try:
            if self.config.coverage_write_headline:
                write_header(op, 'Undocumented Python objects', '=')
            keys = list(self.py_undoc.keys())
            keys.sort()
            for name in keys:
                undoc = self.py_undoc[name]
                if 'error' in undoc:
                    failed.append((name, undoc['error']))
                else:
                    if not undoc['classes'] and not undoc['funcs']:
                        continue

                    write_header(op, name)
                    if undoc['funcs']:
                        op.write('Functions:\n')
                        op.writelines(' * %s\n' % x for x in undoc['funcs'])
                        op.write('\n')
                    if undoc['classes']:
                        op.write('Classes:\n')
                        for name, methods in sorted(
                                                 undoc['classes'].items()):
                            if not methods:
                                op.write(' * %s\n' % name)
                            else:
                                op.write(' * %s -- missing methods:\n\n' % name)
                                op.writelines('   - %s\n' % x for x in methods)
                        op.write('\n')

            if failed:
                write_header(op, 'Modules that failed to import')
                op.writelines(' * %s -- %s\n' % x for x in failed)
        finally:
            op.close()

    def finish(self):
        # dump the coverage data to a pickle file too
        picklepath = path.join(self.outdir, 'undoc.pickle')
        dumpfile = open(picklepath, 'wb')
        try:
            pickle.dump((self.py_undoc, self.c_undoc), dumpfile)
        finally:
            dumpfile.close()


def setup(app):
    app.add_builder(CoverageBuilder)
    app.add_config_value('coverage_ignore_modules', [], False)
    app.add_config_value('coverage_ignore_functions', [], False)
    app.add_config_value('coverage_ignore_classes', [], False)
    app.add_config_value('coverage_c_path', [], False)
    app.add_config_value('coverage_c_regexes', {}, False)
    app.add_config_value('coverage_ignore_c_items', {}, False)
    app.add_config_value('coverage_write_headline', True, False)
    app.add_config_value('coverage_skip_undoc_in_source', False, False)
