"""
@file
@brief  Modification to doxypy.py
(which you can found here: http://code.foosel.org/doxypy).

The main tweaks are:

- the documentation is not moved before the function or class definition
- it uses a function to modify every line of documentation to use rst syntax.
"""
from __future__ import print_function
import sys
import re
from argparse import ArgumentParser as OptionParser


_allowed = re.compile("^([a-zA-Z]:)?[^:*?\"<>|]+$")


def is_file_string(s):
    """
    Says if the string s could be a filename.

    @param      s       string
    @return             boolean
    """
    if len(s) >= 3000:
        return False
    global _allowed
    if not _allowed.search(s):
        return False
    for c in s:
        if ord(c) < 32:
            return False
    return True


__applicationName__ = "doxypy"
__blurb__ = """
doxypy is an input filter for Doxygen. It preprocesses python
files so that docstrings of classes and functions are reformatted
into Doxygen-conform documentation blocks.
"""

__doc__ = __blurb__ + \
    """
In order to make Doxygen preprocess files through doxypy, simply
add the following lines to your Doxyfile:

    - ``FILTER_SOURCE_FILES = YES``
    - ``INPUT_FILTER = "python /path/to/doxypy.py"``
"""

__version__ = "0.4.2"
__date__ = "14th October 2009"
__website__ = "http://code.foosel.org/doxypy"

__author__ = (
    "Philippe 'demod' Neumann (doxypy at demod dot org)",
    "Gina 'foosel' Haeussge (gina at foosel dot net)"
)

__licenseName__ = "GPL v2"
__license__ = """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.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see `GNU <http://www.gnu.org/licenses/>`_.
"""


class FSM(object):

    """
    Implements a finite state machine.

    Transitions are given as 4-tuples, consisting of an origin state, a target
    state, a condition for the transition (given as a reference to a function
    which gets called with a given piece of input) and a pointer to a function
    to be called upon the execution of the given transition.

    @var transitions holds the transitions
    @var current_state holds the current state
    @var current_input holds the current input
    @var current_transition hold the currently active transition
    """

    def __init__(self, start_state=None, transitions=None):
        if transitions is None:
            transitions = []
        self.transitions = transitions
        self.current_state = start_state
        self.current_input = None
        self.current_transition = None

    def setStartState(self, state):
        self.current_state = state

    def addTransition(self, from_state, to_state, condition, callback):
        self.transitions.append([from_state, to_state, condition, callback])

    def makeTransition(self, input):
        """
        Makes a transition based on the given input.

        @param  input   input to parse by the FSM
        """
        for transition in self.transitions:
            [from_state, to_state, condition, callback] = transition
            if from_state == self.current_state:
                match = condition(input)
                if match:
                    self.current_state = to_state
                    self.current_input = input
                    self.current_transition = transition
                    if options.debug:  # pragma: no cover
                        sys.stderr.write(
                            f"# FSM: executing ({from_state} -> {to_state}) for line '{input}'\n")
                    callback(match)
                    return


class Doxypy(object):

    def __init__(self, print_output, process_comment, information):
        """
        Constructor for Doxypy.

        @param      print_output        function which will receive the output
        @param      process_comment     function applied to the help to modify it
        @param      information         a dictionary with additional information such
                                        as filename, first_row
        """
        string_prefixes = "[uU]?[rR]?"

        self.start_single_comment_re = re.compile(
            f"^\\s*{string_prefixes}(''')")
        self.end_single_comment_re = re.compile("(''')\\s*$")

        self.start_double_comment_re = re.compile(
            f"^\\s*{string_prefixes}(\"\"\")")
        self.end_double_comment_re = re.compile("(\"\"\")\\s*$")

        self.single_comment_re = re.compile(
            f"^\\s*{string_prefixes}(''').*(''')\\s*$")
        self.double_comment_re = re.compile(
            f"^\\s*{string_prefixes}(\"\"\").*(\"\"\")\\s*$")

        self.defclass_re = re.compile(
            "^(\\s*)(def .+:|class .+:)\\s*([#].*?)?$")
        self.empty_re = re.compile("^\\s*$")
        self.hashline_re = re.compile("^\\s*#.*$")
        self.importline_re = re.compile("^\\s*(import |from .+ import)")

        self.multiline_defclass_start_re = re.compile(
            "^(\\s*)(def|class)(\\s.*)?$")
        self.multiline_defclass_end_re = re.compile(":\\s*([#].*?)?$")
        self.print_output = print_output
        self.process_comment = process_comment
        self.information = information

        # Transition list format
        #  ["FROM", "TO", condition, action]
        transitions = [
            # FILEHEAD

            # single line comments
            ["FILEHEAD", "FILEHEAD", self.single_comment_re.search,
                self.appendCommentLine],
            ["FILEHEAD", "FILEHEAD", self.double_comment_re.search,
                self.appendCommentLine],

            # multiline comments
            ["FILEHEAD", "FILEHEAD_COMMENT_SINGLE",
                self.start_single_comment_re.search, self.appendCommentLine],
            ["FILEHEAD_COMMENT_SINGLE", "FILEHEAD",
                self.end_single_comment_re.search, self.appendCommentLine],
            ["FILEHEAD_COMMENT_SINGLE", "FILEHEAD_COMMENT_SINGLE",
                self.catchall, self.appendCommentLine],
            ["FILEHEAD", "FILEHEAD_COMMENT_DOUBLE",
                self.start_double_comment_re.search, self.appendCommentLine],
            ["FILEHEAD_COMMENT_DOUBLE", "FILEHEAD",
                self.end_double_comment_re.search, self.appendCommentLine],
            ["FILEHEAD_COMMENT_DOUBLE", "FILEHEAD_COMMENT_DOUBLE",
                self.catchall, self.appendCommentLine],

            # other lines
            ["FILEHEAD", "FILEHEAD", self.empty_re.search,
                self.appendFileheadLine],
            ["FILEHEAD", "FILEHEAD", self.hashline_re.search,
                self.appendFileheadLine],
            ["FILEHEAD", "FILEHEAD", self.importline_re.search,
                self.appendFileheadLine],
            ["FILEHEAD", "DEFCLASS", self.defclass_re.search,
                self.resetCommentSearch],
            ["FILEHEAD", "DEFCLASS_MULTI",
                self.multiline_defclass_start_re.search, self.resetCommentSearch],
            ["FILEHEAD", "DEFCLASS_BODY",
                self.catchall, self.appendFileheadLine],

            # DEFCLASS

            # single line comments
            ["DEFCLASS", "DEFCLASS_BODY",
                self.single_comment_re.search, self.appendCommentLine],
            ["DEFCLASS", "DEFCLASS_BODY",
                self.double_comment_re.search, self.appendCommentLine],

            # multiline comments
            ["DEFCLASS", "COMMENT_SINGLE",
                self.start_single_comment_re.search, self.appendCommentLine],
            ["COMMENT_SINGLE", "DEFCLASS_BODY",
                self.end_single_comment_re.search, self.appendCommentLine],
            ["COMMENT_SINGLE", "COMMENT_SINGLE",
                self.catchall, self.appendCommentLine],
            ["DEFCLASS", "COMMENT_DOUBLE",
                self.start_double_comment_re.search, self.appendCommentLine],
            ["COMMENT_DOUBLE", "DEFCLASS_BODY",
                self.end_double_comment_re.search, self.appendCommentLine],
            ["COMMENT_DOUBLE", "COMMENT_DOUBLE",
                self.catchall, self.appendCommentLine],

            # other lines
            ["DEFCLASS", "DEFCLASS", self.empty_re.search,
                self.appendDefclassLine],
            ["DEFCLASS", "DEFCLASS", self.defclass_re.search,
                self.resetCommentSearch],
            ["DEFCLASS", "DEFCLASS_MULTI",
                self.multiline_defclass_start_re.search, self.resetCommentSearch],
            ["DEFCLASS", "DEFCLASS_BODY",
                self.catchall, self.stopCommentSearch],

            # DEFCLASS_BODY

            ["DEFCLASS_BODY", "DEFCLASS",
                self.defclass_re.search, self.startCommentSearch],
            ["DEFCLASS_BODY", "DEFCLASS_MULTI",
                self.multiline_defclass_start_re.search, self.startCommentSearch],
            ["DEFCLASS_BODY", "DEFCLASS_BODY",
                self.catchall, self.appendNormalLine],

            # DEFCLASS_MULTI
            ["DEFCLASS_MULTI", "DEFCLASS",
                self.multiline_defclass_end_re.search, self.appendDefclassLine],
            ["DEFCLASS_MULTI", "DEFCLASS_MULTI",
                self.catchall, self.appendDefclassLine],
        ]

        self.fsm = FSM("FILEHEAD", transitions)
        self.outstream = sys.stdout

        self.output = []
        self.comment = []
        self.filehead = []
        self.defclass = []
        self.indent = ""

    def __closeComment(self):
        """
        Appends any open comment block and triggering block to the output.
        """

        if options.autobrief:
            if len(self.comment) == 1 \
                    or (len(self.comment) > 2 and self.comment[1].strip() == ''):
                self.comment[0] = self.__docstringSummaryToBrief(
                    self.comment[0])

        if self.defclass:
            self.output.extend(self.defclass)

        if self.comment:
            block = self.makeCommentBlock()
            self.output.extend(block)

    def __docstringSummaryToBrief(self, line):
        """
        Adds \\brief to the docstrings summary line.

        A \\brief is prepended, provided no other doxygen command is at the
        start of the line.
        """
        stripped = line.strip()
        if stripped and not stripped[0] in ('@', '\\'):
            return "@brief " + line
        else:
            return line

    def __flushBuffer(self):
        """
        Flushes the current outputbuffer to the outstream.
        """
        if self.output:
            if options.debug:  # pragma: no cover
                sys.stderr.write(f"# OUTPUT: {self.output}\n")
            self.print_output("\n".join(self.output), file=self.outstream)
            self.outstream.flush()
        self.output = []

    def catchall(self, input):
        """
        The catchall-condition, always returns true.
        """
        return True

    def resetCommentSearch(self, match):
        """
        Restarts a new comment search for a different triggering line.

        Closes the current commentblock and starts a new comment search.
        """
        if options.debug:  # pragma: no cover
            sys.stderr.write("# CALLBACK: resetCommentSearch")
        self.__closeComment()
        self.startCommentSearch(match)

    def startCommentSearch(self, match):
        """
        Starts a new comment search.

        Saves the triggering line, resets the current comment and saves
        the current indentation.
        """
        if options.debug:  # pragma: no cover
            sys.stderr.write("# CALLBACK: startCommentSearch")
        self.defclass = [self.fsm.current_input]
        self.comment = []
        self.indent = match.group(1)

    def stopCommentSearch(self, match):
        """
        Stops a comment search.

        Closes the current commentblock, resets the triggering line and
        appends the current line to the output.
        """
        if options.debug:  # pragma: no cover
            sys.stderr.write("# CALLBACK: stopCommentSearch")
        self.__closeComment()

        self.defclass = []
        self.output.append(self.fsm.current_input)

    def appendFileheadLine(self, match):
        """
        Appends a line in the FILEHEAD state.

        Closes the open comment block, resets it and appends the current line.
        """
        if options.debug:  # pragma: no cover
            sys.stderr.write("# CALLBACK: appendFileheadLine")
        self.__closeComment()
        self.comment = []
        self.output.append(self.fsm.current_input)

    def appendCommentLine(self, match):
        """
        Appends a comment line.

        The comment delimiter is removed from multiline start and ends as
        well as singleline comments.
        """
        if options.debug:  # pragma: no cover
            sys.stderr.write("# CALLBACK: appendCommentLine")
        from_state, to_state, condition, callback = self.fsm.current_transition  # pylint: disable=W0612

        # single line comment
        if (from_state == "DEFCLASS" and to_state == "DEFCLASS_BODY") \
                or (from_state == "FILEHEAD" and to_state == "FILEHEAD"):
            # remove comment delimiter from begin and end of the line
            activeCommentDelim = match.group(1)
            line = self.fsm.current_input
            self.comment.append(line[line.find(
                activeCommentDelim) + len(activeCommentDelim):line.rfind(activeCommentDelim)])

            if (to_state == "DEFCLASS_BODY"):
                self.__closeComment()
                self.defclass = []
        # multiline start
        elif from_state in ("DEFCLASS", "FILEHEAD"):
            # remove comment delimiter from begin of the line
            activeCommentDelim = match.group(1)
            line = self.fsm.current_input
            self.comment.append(
                line[line.find(activeCommentDelim) + len(activeCommentDelim):])
        # multiline end
        elif to_state in ("DEFCLASS_BODY", "FILEHEAD"):
            # remove comment delimiter from end of the line
            activeCommentDelim = match.group(1)
            line = self.fsm.current_input
            self.comment.append(line[0:line.rfind(activeCommentDelim)])
            if (to_state == "DEFCLASS_BODY"):
                self.__closeComment()
                self.defclass = []
        # in multiline comment
        else:
            # just append the comment line
            self.comment.append(self.fsm.current_input)

    def appendNormalLine(self, match):
        """
        Appends a line to the output.
        """
        if options.debug:  # pragma: no cover
            self.print_output("# CALLBACK: appendNormalLine", file=sys.stderr)
        self.output.append(self.fsm.current_input)

    def appendDefclassLine(self, match):
        """
        Appends a line to the triggering block.
        """
        if options.debug:  # pragma: no cover
            self.print_output(
                "# CALLBACK: appendDefclassLine", file=sys.stderr)
        self.defclass.append(self.fsm.current_input)

    def makeCommentBlock(self):
        """
        Indents the current comment block with respect to the current
        indentation level.

        @returns a list of indented comment lines
        """
        if options.debug:  # pragma: no cover
            self.print_output("# makeCommentBlock", file=sys.stderr)
        indent4 = "    " if len(self.defclass) > 0 else ""
        doxyStart = f"{indent4}\"\"\""
        doxyEnd = f"{indent4}\"\"\""

        commentLines = self.comment
        full_indent = self.indent + indent4
        if full_indent:
            if commentLines and commentLines[0].lstrip() == commentLines[0]:
                commentLines[0] = full_indent + commentLines[0]
        # commentLines = ["%s%s%s" % (self.indent, indent4, x) for x in commentLines]

        commentLines = self.process_comment(commentLines,
                                            self.information.get(
                                                "first_row", 0) + self._index_row + 1,
                                            self.information.get("filename", "filename is not present"))

        # We remove the indentation.
        while commentLines and commentLines[0].strip() == '':
            del commentLines[0]
        while commentLines and commentLines[-1].strip() == '':
            del commentLines[-1]

        # Back to doxypy.
        li = [self.indent + doxyStart]
        li.extend(commentLines)
        li.append(self.indent + doxyEnd)

        return li

    def parse(self, input):
        """
        Parses a python file given as input string and returns the doxygen-
        compatible representation.

        @param  input   the python code to parse
        @returns the modified python code
        """
        lines = input.split("\n")

        for line in lines:
            self.fsm.makeTransition(line)

        if self.fsm.current_state == "DEFCLASS":
            self.__closeComment()

        return "\n".join(self.output)

    def parseFile(self, filename):
        """
        Parses a :epkg:`python` file given as input string and returns`
        the :epkg:`doxygen` compatible representation.

        @param  filename        the :epkg:`python` code to parse (filename)
        """
        self._index_row = 0
        if isinstance(filename, list):
            for line in filename:
                self.parseLine(line.rstrip('\r\n'))
                self._index_row += 1
        else:
            import os
            if is_file_string(filename) and os.path.exists(filename):
                with open(filename, 'r') as fh:
                    for line in fh:
                        self.parseLine(line.rstrip('\r\n'))
                    self._index_row += 1
            else:
                for line in filename.split("\n"):
                    self.parseLine(line.rstrip('\r\n'))
                    self._index_row += 1

        if self.fsm.current_state == "DEFCLASS":
            self.__closeComment()
            self.__flushBuffer()

    def parseLine(self, line):
        """
        Parses one line of python and flush the resulting output to the
        outstream.

        @param  line    the python code line to parse
        """
        self.fsm.makeTransition(line)
        self.__flushBuffer()


def optParse():
    """
    Parses commandline options.
    """
    parser = OptionParser(prog=__applicationName__)

    # parser.set_usage("%prog [options] filename")
    parser.add_argument("--autobrief",
                        action="store_true", dest="autobrief",
                        help="use the docstring summary line as @brief description",
                        version="%prog " + __version__
                        )
    parser.add_argument("--debug",
                        action="store_true", dest="debug",
                        help="enable debug output on stderr",
                        version="%prog " + __version__
                        )

    # parse options
    global options
    (options, filename) = parser.parse_args()

    if not filename:
        sys.stderr.write("No filename given.")
        sys.exit(-1)

    return filename[0]


def main(file=None, print_output=None):
    """
    Starts the parser on the file given by the filename as the first
    argument on the commandline.

    @param      file        if equal to None, take this one on the command line
    @param      print_output    every string is sent to that funtion
    """
    filename = file if file is not None else optParse()
    fsm = Doxypy(print_output, lambda a, b, c: a, {})
    fsm.parseFile(filename)


class Opt:

    def __init__(self):
        self.debug = False
        self.autobrief = True


options = Opt()


def process_string(content, print_output, process_comment, filename, first_row, debug=False):
    """
    Applies the doxypy like process to a string.

    @param      content             string
    @param      print_output        every string is sent to that funtion
    @param      process_comment     function applied to the help to modifies it
    @param      filename            or None if there is no file
    @param      first_row           True: to display error messages
    @param      debug               if True, display more information
    """
    options.debug = debug
    fsm = Doxypy(print_output, process_comment,
                 {"filename": filename, "first_row": first_row})
    fsm.parseFile(content)


if __name__ == "__main__":
    with open(__file__, "r") as f:
        local_content = f.read()
    # main(__file__, print_output = print)

    def pprint(*la, **kw):
        print(*la, **kw)

    process_string(local_content, pprint, lambda a, b, c: a, "", 0)
