# -*- coding: utf-8 -*-

"""
***************************************************************************
    mocked
    ---------------------
    Date                 : May 2017
    Copyright            : (C) 2017 by Denis Rouzaud
    Email                : denis.rouzaud@gmail.com
***************************************************************************
*                                                                         *
*   This program is free software; you can redistribute it and/or modify  *
*   it under the terms of the GNU General Public License as published by  *
*   the Free Software Foundation; either version 2 of the License, or     *
*   (at your option) any later version.                                   *
*                                                                         *
***************************************************************************
"""

__author__ = 'Denis Rouzaud'
__date__ = 'May 2017'
__copyright__ = '(C) 2017, Denis Rouzaud'

# This will get replaced with a git SHA1 when you do a git archive

__revision__ = ':%H$'

import re
import glob
import os

try:
    import xml.etree.cElementTree as ET
except ImportError:
    import xml.etree.ElementTree as ET


class DoxygenParser():

    """
    Parses the XML files generated by Doxygen which describe the API docs
    """

    def __init__(self, path, acceptable_missing={}, acceptable_missing_added_note=[], acceptable_missing_brief=[]):
        """
        Initializes the parser.
        :param path: Path to Doxygen XML output
        """
        self.acceptable_missing = acceptable_missing
        self.acceptable_missing_added_note = acceptable_missing_added_note
        self.acceptable_missing_brief = acceptable_missing_brief
        self.documentable_members = 0
        self.documented_members = 0
        self.undocumented_members = {}
        self.noncompliant_members = {}
        self.broken_links = {}
        self.bindable_members = []
        self.groups = {}
        self.classes_missing_group = []
        self.classes_missing_brief = []
        self.classes_missing_version_added = []
        # for some reason the Doxygen generation on Travis refuses to assign these classes to groups
        self.acceptable_missing_group = ['QgsOgcUtils::LayerProperties',
                                         'QgsSQLStatement::Node',
                                         'QgsSQLStatement::NodeBinaryOperator',
                                         'QgsSQLStatement::NodeColumnRef',
                                         'QgsSQLStatement::NodeFunction',
                                         'QgsSQLStatement::NodeInOperator',
                                         'QgsSQLStatement::NodeList',
                                         'QgsSQLStatement::NodeLiteral',
                                         'QgsSQLStatement::NodeUnaryOperator',
                                         'QgsRuleBasedLabeling::Rule',
                                         'QgsSQLStatement::Visitor']
        self.version_regex = re.compile(r'QGIS [\d\.]+.*')
        self.parseFiles(path)

    def parseFiles(self, path):
        """ Parses all the Doxygen XML files in a folder
            :param path: Path to Doxygen XML output
        """

        found = False
        # find groups
        for f in glob.glob(os.path.join(path, 'group__*.xml')):
            found = True
            group, members = self.parseGroup(f)
            self.groups[group] = members

        assert found, "Could not find doxygen groups xml"
        found = False
        # parse docs
        for f in glob.glob(os.path.join(path, '*.xml')):
            found = True
            self.parseFile(f)
        assert found, "Could not find doxygen files xml"

    def parseGroup(self, f):
        """ Parses a single Doxygen Group XML file
            :param f: XML file path
        """
        name = None
        members = []

        # Wrap everything in a try, as sometimes Doxygen XML is malformed
        try:
            for event, elem in ET.iterparse(f):
                if event == 'end' and elem.tag == 'compoundname':
                    name = elem.text
                if event == 'end' and elem.tag == 'innerclass':
                    members.append(elem.text)
        except:
            pass

        return name, members

    def hasGroup(self, class_name):
        """ Returns true if a class has been assigned to a group
            :param class_name class name to test
        """
        for g in self.groups:
            if class_name in self.groups[g]:
                return True
        return False

    @staticmethod
    def standardize_signature(signature):
        """
        Standardizes a method's signature for comparison
        """
        return signature.lower().replace('* >', '*>').replace('< ', '<')

    def parseFile(self, f):
        """ Parses a single Doxygen XML file
            :param f: XML file path
        """
        documentable_members = 0
        documented_members = 0

        # Wrap everything in a try, as sometimes Doxygen XML is malformed
        try:
            for event, elem in ET.iterparse(f):
                if event == 'end' and elem.tag == 'compounddef':
                    if self.elemIsPublicClass(elem):
                        # store documentation status
                        members, documented, undocumented, noncompliant, bindable, has_brief_description, found_version_added, broken_links = self.parseClassElem(elem)
                        documentable_members += members
                        documented_members += documented
                        class_name = elem.find('compoundname').text
                        acceptable_missing = self.acceptable_missing.get(class_name, [])

                        if not self.hasGroup(class_name) and class_name not in self.acceptable_missing_group:
                            self.classes_missing_group.append(class_name)
                        if class_name not in self.acceptable_missing_brief and not has_brief_description:
                            self.classes_missing_brief.append(class_name)
                        if class_name not in self.acceptable_missing_added_note and not found_version_added:
                            self.classes_missing_version_added.append(class_name)

                        # GEN LIST
                        # if len(undocumented) > 0:
                        #     print('"%s": [%s],' % (class_name, ", ".join(['"%s"' % e.replace('"', '\\"') for e in undocumented])))

                        unacceptable_undocumented = undocumented - set(acceptable_missing)

                        # do a case insensitive check too
                        unacceptable_undocumented_insensitive = set([DoxygenParser.standardize_signature(u) for u in undocumented]) - set([DoxygenParser.standardize_signature(u) for u in acceptable_missing])

                        if len(unacceptable_undocumented_insensitive) > 0:
                            self.undocumented_members[class_name] = {}
                            self.undocumented_members[class_name]['documented'] = documented
                            self.undocumented_members[class_name]['members'] = members
                            self.undocumented_members[class_name]['missing_members'] = unacceptable_undocumented

                        if len(noncompliant) > 0:
                            self.noncompliant_members[class_name] = noncompliant

                        if broken_links:
                            self.broken_links[class_name] = broken_links

                        # store bindable members
                        if self.classElemIsBindable(elem):
                            for m in bindable:
                                self.bindable_members.append(m)

                    elem.clear()
        except ET.ParseError as e:
            # sometimes Doxygen generates malformed xml (e.g., for < and > operators)
            line_num, col = e.position
            with open(f, 'r') as xml_file:
                for i, l in enumerate(xml_file):
                    if i == line_num - 1:
                        line = l
                        break
            caret = '{:=>{}}'.format('^', col)
            print(('ParseError in {}\n{}\n{}\n{}'.format(f, e, line, caret)))

        self.documentable_members += documentable_members
        self.documented_members += documented_members

    def elemIsPublicClass(self, elem):
        """ Tests whether an XML element corresponds to a public (or protected) class
            :param elem: XML element
        """

        # only looking for classes
        if not elem.get('kind') == 'class':
            return False

        # only looking for public or protected classes
        return elem.get('prot') in ('public', 'protected')

    def classElemIsBindable(self, elem):
        """ Tests whether a class should have SIP bindings
            :param elem: XML element corresponding to a class
        """
        try:
            # check for classes with special python doc notes (probably 'not available' or renamed classes, either way
            # they should be safe to ignore as obviously some consideration has been given to Python bindings)
            detailed_sec = elem.find('detaileddescription')
            for p in detailed_sec.getiterator('para'):
                for s in p.getiterator('simplesect'):
                    for ps in s.getiterator('para'):
                        if ps.text and 'python' in ps.text.lower():
                            return False
            return True
        except:
            return True

    def parseClassElem(self, e):
        """ Parses an XML element corresponding to a Doxygen class
            :param e: XML element
        """
        documentable_members = 0
        documented_members = 0
        undocumented_members = set()
        noncompliant_members = []
        bindable_members = []
        broken_links = {}
        # loop through all members
        for m in e.getiterator('memberdef'):
            signature = self.memberSignature(m)
            if signature is None:
                continue
            if self.elemIsBindableMember(m):
                bindable_member = [e.find('compoundname').text, m.find('name').text]
                if bindable_member not in bindable_members:
                    bindable_members.append(bindable_member)
            if self.elemIsDocumentableMember(m):
                documentable_members += 1
                if self.memberIsDocumented(m):
                    documented_members += 1
                    error = self.memberDocIsNonCompliant(m)
                    if error:
                        noncompliant_members.append({m.find('name').text: error})
                else:
                    undocumented_members.add(signature)

                broken_see_also_links = self.checkForBrokenSeeAlsoLinks(m)
                if broken_see_also_links:
                    broken_links[m.find('name').text] = broken_see_also_links

        # test for brief description
        d = e.find('briefdescription')
        has_brief_description = False
        if d:
            has_brief_description = True

        # test for "added in QGIS xxx" string
        d = e.find('detaileddescription')
        found_version_added = False
        for para in d.getiterator('para'):
            for s in para.getiterator('simplesect'):
                if s.get('kind') == 'since':
                    for p in s.getiterator('para'):
                        if self.version_regex.match(p.text):
                            found_version_added = True
                            break
            if found_version_added:
                break

        return documentable_members, documented_members, undocumented_members, noncompliant_members, bindable_members, has_brief_description, found_version_added, broken_links

    def memberSignature(self, elem):
        """ Returns the signature for a member
            :param elem: XML element for a class member
        """
        a = elem.find('argsstring')
        try:
            if a is not None:
                signature = elem.find('name').text + a.text
            else:
                signature = elem.find('name').text
            if signature.endswith('= default'):
                signature = signature[:-len('= default')]
            return signature.strip()
        except:
            return None

    def elemIsBindableMember(self, elem):
        """ Tests whether an member should be included in SIP bindings
            :param elem: XML element for a class member
        """

        # only public or protected members are bindable
        if not self.visibility(elem) in ('public', 'protected'):
            return False

        # property themselves are not bound, only getters and setters
        if self.isProperty(elem):
            return False

        # ignore friend classes
        if self.isFriendClass(elem):
            return False

        # ignore typedefs (can't test for them)
        if self.isTypeDef(elem):
            return False

        if self.isVariable(elem) and self.visibility(elem) == 'protected':
            # protected variables can't be bound in SIP
            return False

        # check for members with special python doc notes (probably 'not available' or renamed methods, either way
        # they should be safe to ignore as obviously some consideration has been given to Python bindings)
        try:
            detailed_sec = elem.find('detaileddescription')
            for p in detailed_sec.getiterator('para'):
                for s in p.getiterator('simplesect'):
                    for ps in s.getiterator('para'):
                        if ps.text and 'python' in ps.text.lower():
                            return False
        except:
            pass

        # ignore constructors and destructor, can't test for these
        if self.isDestructor(elem) or self.isConstructor(elem):
            return False

        # ignore operators, also can't test
        if self.isOperator(elem):
            return False

        # ignore deprecated members
        if self.isDeprecated(elem):
            return False

        return True

    def elemIsDocumentableMember(self, elem):
        """ Tests whether an member should be included in Doxygen docs
            :param elem: XML element for a class member
        """

        # ignore variables (for now, eventually public/protected variables should be documented)
        if self.isVariable(elem):
            return False

        # only public or protected members should be documented
        if not self.visibility(elem) in ('public', 'protected'):
            return False

        # ignore reimplemented methods
        if self.isReimplementation(elem):
            return False

        # ignore friend classes
        if self.isFriendClass(elem):
            return False

        # ignore destructor
        if self.isDestructor(elem):
            return False

        # ignore constructors with no arguments
        if self.isConstructor(elem):
            try:
                if elem.find('argsstring').text == '()':
                    return False
            except:
                pass

        name = elem.find('name')

        # ignore certain obvious operators
        try:
            if name.text in ('operator=', 'operator==', 'operator!=', 'Q_ENUM'):
                return False
        except:
            pass

        # ignore on_* slots
        try:
            if name.text.startswith('on_'):
                return False
        except:
            pass

        # ignore deprecated members
        if self.isDeprecated(elem):
            return False

        return True

    def visibility(self, elem):
        """ Returns the visibility of a class or member
            :param elem: XML element for a class or member
        """
        try:
            return elem.get('prot')
        except:
            return ''

    def isVariable(self, member_elem):
        """ Tests whether an member is a variable
            :param member_elem: XML element for a class member
        """
        try:
            if member_elem.get('kind') == 'variable':
                return True
        except:
            pass

        return False

    def isProperty(self, member_elem):
        """ Tests whether an member is a property
            :param member_elem: XML element for a class member
        """
        try:
            if member_elem.get('kind') == 'property':
                return True
        except:
            pass

        return False

    def isDestructor(self, member_elem):
        """ Tests whether an member is a destructor
            :param member_elem: XML element for a class member
        """
        try:
            name = member_elem.find('name').text
            if name.startswith('~'):
                # destructor
                return True
        except:
            pass
        return False

    def isConstructor(self, member_elem):
        """ Tests whether an member is a constructor
            :param member_elem: XML element for a class member
        """
        try:
            definition = member_elem.find('definition').text
            name = member_elem.find('name').text
            if '{}::{}'.format(name, name) in definition:
                return True
        except:
            pass

        return False

    def isOperator(self, member_elem):
        """ Tests whether an member is an operator
            :param member_elem: XML element for a class member
        """
        try:
            name = member_elem.find('name').text
            if re.match('^operator\W.*', name):
                return True
        except:
            pass

        return False

    def isFriendClass(self, member_elem):
        """ Tests whether an member is a friend class
            :param member_elem: XML element for a class member
        """
        try:
            if member_elem.get('kind') == 'friend':
                return True
        except:
            pass
        return False

    def isTypeDef(self, member_elem):
        """ Tests whether an member is a type def
            :param member_elem: XML element for a class member
        """
        try:
            if member_elem.get('kind') == 'typedef':
                return True
        except:
            pass
        return False

    def isReimplementation(self, member_elem):
        """ Tests whether an member is a reimplementation
            :param member_elem: XML element for a class member
        """

        # use two different tests, as Doxygen will not detect reimplemented Qt methods
        try:
            if member_elem.find('reimplements') is not None:
                return True
            if ' override' in member_elem.find('argsstring').text:
                return True
        except:
            pass

        return False

    def isDeprecated(self, member_elem):
        """ Tests whether an member is deprecated
            :param member_elem: XML element for a class member
        """

        # look for both Q_DECL_DEPRECATED and Doxygen deprecated tag
        decl_deprecated = False
        type_elem = member_elem.find('type')
        try:
            if 'Q_DECL_DEPRECATED' in type_elem.text:
                decl_deprecated = True
        except:
            pass

        doxy_deprecated = False
        has_description = True
        try:
            for p in member_elem.find('detaileddescription').getiterator('para'):
                for s in p.getiterator('xrefsect'):
                    if s.find('xreftitle') is not None and 'Deprecated' in s.find('xreftitle').text:
                        doxy_deprecated = True
                        if s.find('xrefdescription') is None or s.find('xrefdescription').find('para') is None:
                            has_description = False
                        break
        except:
            assert 0, member_elem.find('definition').text

        if not decl_deprecated and not doxy_deprecated:
            return False

        if doxy_deprecated and not has_description:
            assert has_description, 'Error: Missing description for deprecated method {}'.format(
                member_elem.find('definition').text)

        # only functions for now, but in future this should also apply for enums and variables
        if member_elem.get('kind') in ('function', 'variable'):
            assert decl_deprecated, 'Error: Missing Q_DECL_DEPRECATED for {}'.format(member_elem.find('definition').text)
            assert doxy_deprecated, 'Error: Missing Doxygen deprecated tag for {}'.format(member_elem.find('definition').text)

        return True

    def memberIsDocumented(self, member_elem):
        """ Tests whether an member has documentation
            :param member_elem: XML element for a class member
        """
        for doc_type in ('inbodydescription', 'briefdescription', 'detaileddescription'):
            doc = member_elem.find(doc_type)
            if doc is not None and list(doc):
                return True
        return False

    def memberDocIsNonCompliant(self, member_elem):
        """ Tests whether an member's documentation is non-compliant
            :param member_elem: XML element for a class member
        """
        for doc_type in ['briefdescription']:
            doc = member_elem.find(doc_type)
            if doc is not None:
                for para in doc.getiterator('para'):
                    if not para.text:
                        continue
                    if para.text.strip().lower().startswith('getter'):
                        return 'Use "Returns the..." instead of "getter"'
                    if para.text.strip().lower().startswith('get '):
                        return 'Use "Gets..." (or better, "Returns ...") instead of "get ..."'
                    elif para.text.strip().lower().startswith('setter'):
                        return 'Use "Sets the..." instead of "setter"'
                    elif para.text.strip().lower().startswith('mutator'):
                        return 'Use "Sets the..." instead of "mutator for..."'
                    elif para.text.strip().lower().startswith('accessor'):
                        return 'Use "Returns the..." instead of "accessor for..."'
                    elif para.text.strip().lower().startswith('return '):
                        return 'Use "Returns the..." instead of "return ..."'
                    #elif para.text.strip().lower().startswith('set '):
                    #    return 'Use "Sets the..." instead of "set ..."'
        return False

    def checkForBrokenSeeAlsoLinks(self, elem):
        """
        Checks for any broken 'see also' links
        """
        broken = []
        detailed_sec = elem.find('detaileddescription')
        for p in detailed_sec.getiterator('para'):
            for s in p.getiterator('simplesect'):
                if s.get('kind') != 'see':
                    continue

                para = s.getchildren()[0]
                if para.find('ref') is None and para.text and (not para.text.startswith('Q') or para.text.startswith('Qgs')):
                    broken.append(para.text)

        return broken
