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

# Copyright (c) 2005 - 2023 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Parse a Ruby file and retrieve classes, modules, methods and attributes.

Parse enough of a Ruby file to recognize class, module and method definitions
and to find out the superclasses of a class as well as its attributes.

It is based on the Python class browser found in this package.
"""

import re

from PyQt6.QtCore import QRegularExpression

from eric7 import Utilities
from eric7.Utilities import ClassBrowsers

from . import ClbrBaseClasses

SUPPORTED_TYPES = [ClassBrowsers.RB_SOURCE]

_getnext = QRegularExpression(
    r"""
    (?P<String>
        =begin .*? =end
    
    |   <<-? (?P<HereMarker1> [a-zA-Z0-9_]+? ) [ \t]* .*? (?P=HereMarker1)

    |   <<-? ['"] (?P<HereMarker2> [^'"]+? ) ['"] [ \t]* .*? (?P=HereMarker2)

    |   " [^"\\\n]* (?: \\. [^"\\\n]*)* "

    |   ' [^'\\\n]* (?: \\. [^'\\\n]*)* '
    )

|   (?P<CodingLine>
        ^ \# \s* [*_-]* \s* coding[:=] \s* (?P<Coding> [-\w_.]+ ) \s* [*_-]* $
    )

|   (?P<Comment>
        ^
        [ \t]* \#+ .*? $
    )

|   (?P<Method>
        ^
        (?P<MethodIndent> [ \t]* )
        def [ \t]+
        (?:
            (?P<MethodName2> [a-zA-Z0-9_]+ (?: \. | :: )
            [a-zA-Z_] [a-zA-Z0-9_?!=]* )
        |
            (?P<MethodName> [a-zA-Z_] [a-zA-Z0-9_?!=]* )
        |
            (?P<MethodName3> [^( \t]{1,3} )
        )
        [ \t]*
        (?:
            \( (?P<MethodSignature> (?: [^)] | \)[ \t]*,? )*? ) \)
        )?
        [ \t]*
    )

|   (?P<Class>
        ^
        (?P<ClassIndent> [ \t]* )
        class
        (?:
            [ \t]+
            (?P<ClassName> [A-Z] [a-zA-Z0-9_]* )
            [ \t]*
            (?P<ClassSupers> < [ \t]* [A-Z] [a-zA-Z0-9_:]* )?
        |
            [ \t]* << [ \t]*
            (?P<ClassName2> [a-zA-Z_] [a-zA-Z0-9_:]* )
        )
        [ \t]*
    )

|   (?P<ClassIgnored>
        \(
        [ \t]*
        class
        .*?
        end
        [ \t]*
        \)
    )

|   (?P<Module>
        ^
        (?P<ModuleIndent> [ \t]* )
        module [ \t]+
        (?P<ModuleName> [A-Z] [a-zA-Z0-9_:]* )
        [ \t]*
    )

|   (?P<AccessControl>
        ^
        (?P<AccessControlIndent> [ \t]* )
        (?:
            (?P<AccessControlType> private | public | protected ) [^_]
        |
            (?P<AccessControlType2>
            private_class_method | public_class_method )
        )
        \(?
        [ \t]*
        (?P<AccessControlList> (?: : [a-zA-Z0-9_]+ , \s* )*
        (?: : [a-zA-Z0-9_]+ )+ )?
        [ \t]*
        \)?
    )

|   (?P<Attribute>
        ^
        (?P<AttributeIndent> [ \t]* )
        (?P<AttributeName> (?: @ | @@ ) [a-zA-Z0-9_]* )
        [ \t]* =
    )

|   (?P<Attr>
        ^
        (?P<AttrIndent> [ \t]* )
        attr
        (?P<AttrType> (?: _accessor | _reader | _writer ) )?
        \(?
        [ \t]*
        (?P<AttrList> (?: : [a-zA-Z0-9_]+ , \s* )*
        (?: : [a-zA-Z0-9_]+ | true | false )+ )
        [ \t]*
        \)?
    )

|   (?P<Begin>
            ^
            [ \t]*
            (?: def | if | unless | case | while | until | for | begin )
            \b [^_]
        |
            [ \t]* do [ \t]* (?: \| .*? \| )? [ \t]* $
    )

|   (?P<BeginEnd>
        \b (?: if ) \b [^_] .*? $
        |
        \b (?: if ) \b [^_] .*? end [ \t]* $
    )

|   (?P<End>
        [ \t]*
        (?:
            end [ \t]* $
        |
            end \b [^_]
        )
    )""",
    QRegularExpression.PatternOption.MultilineOption
    | QRegularExpression.PatternOption.DotMatchesEverythingOption
    | QRegularExpression.PatternOption.ExtendedPatternSyntaxOption
    | QRegularExpression.PatternOption.UseUnicodePropertiesOption,
).match

_commentsub = re.compile(r"""#[^\n]*\n|#[^\n]*$""").sub


class VisibilityMixin(ClbrBaseClasses.ClbrVisibilityMixinBase):
    """
    Mixin class implementing the notion of visibility.
    """

    def __init__(self):
        """
        Constructor
        """
        self.setPublic()


class Class(ClbrBaseClasses.Class, VisibilityMixin):
    """
    Class to represent a Ruby class.
    """

    def __init__(self, module, name, superClasses, file, lineno):
        """
        Constructor

        @param module name of the module containing this class
        @param name name of this class
        @param superClasses list of class names this class is inherited from
        @param file filename containing this class
        @param lineno linenumber of the class definition
        """
        ClbrBaseClasses.Class.__init__(self, module, name, superClasses, file, lineno)
        VisibilityMixin.__init__(self)


class Module(ClbrBaseClasses.Module, VisibilityMixin):
    """
    Class to represent a Ruby module.
    """

    def __init__(self, module, name, file, lineno):
        """
        Constructor

        @param module name of the module containing this class
        @param name name of this class
        @param file filename containing this class
        @param lineno linenumber of the class definition
        """
        ClbrBaseClasses.Module.__init__(self, module, name, file, lineno)
        VisibilityMixin.__init__(self)


class Function(ClbrBaseClasses.Function, VisibilityMixin):
    """
    Class to represent a Ruby function.
    """

    def __init__(self, module, name, file, lineno, signature="", separator=","):
        """
        Constructor

        @param module name of the module containing this function
        @param name name of this function
        @param file filename containing this class
        @param lineno linenumber of the class definition
        @param signature parameterlist of the method
        @param separator string separating the parameters
        """
        ClbrBaseClasses.Function.__init__(
            self, module, name, file, lineno, signature, separator
        )
        VisibilityMixin.__init__(self)


class Attribute(ClbrBaseClasses.Attribute, VisibilityMixin):
    """
    Class to represent a class or module attribute.
    """

    def __init__(self, module, name, file, lineno):
        """
        Constructor

        @param module name of the module containing this class
        @param name name of this class
        @param file filename containing this attribute
        @param lineno linenumber of the class definition
        """
        ClbrBaseClasses.Attribute.__init__(self, module, name, file, lineno)
        VisibilityMixin.__init__(self)
        self.setPrivate()


def readmodule_ex(module, path=None, isTypeFile=False):
    """
    Read a Ruby file and return a dictionary of classes, functions and modules.

    @param module name of the Ruby file
    @type str
    @param path path the file should be searched in
    @type list of str
    @param isTypeFile flag indicating a file of this type
    @type bool
    @return the resulting dictionary
    @rtype dict
    """
    # search the path for the file
    f = None
    fullpath = [] if path is None else path[:]
    f, file, (suff, mode, type) = ClassBrowsers.find_module(module, fullpath)
    if f:
        f.close()
    if type not in SUPPORTED_TYPES:
        # not Ruby source, can't do anything with this module
        return {}

    try:
        src = Utilities.readEncodedFile(file)[0]
    except (UnicodeError, OSError):
        # can't do anything with this module
        return {}

    return scan(src, file, module)


def scan(src, file, module):
    """
    Public method to scan the given source text.

    @param src source text to be scanned
    @type str
    @param file file name associated with the source text
    @type str
    @param module module name associated with the source text
    @type str
    @return dictionary containing the extracted data
    @rtype dict
    """
    # convert eol markers the Python style
    src = src.replace("\r\n", "\n").replace("\r", "\n")

    dictionary = {}
    dict_counts = {}

    classstack = []  # stack of (class, indent) pairs
    acstack = []  # stack of (access control, indent) pairs
    indent = 0

    lineno, last_lineno_pos = 1, 0
    cur_obj = None
    lastGlobalEntry = None
    i = 0
    while True:
        m = _getnext(src, i)
        if not m.hasMatch():
            break
        start, i = m.capturedStart(), m.capturedEnd()

        if m.hasCaptured("Method"):
            # found a method definition or function
            thisindent = indent
            indent += 1
            meth_name = (
                m.captured("MethodName")
                or m.captured("MethodName2")
                or m.captured("MethodName3")
            )
            meth_sig = m.captured("MethodSignature")
            meth_sig = meth_sig and meth_sig.replace("\\\n", "") or ""
            meth_sig = _commentsub("", meth_sig)
            lineno += src.count("\n", last_lineno_pos, start)
            last_lineno_pos = start
            if meth_name.startswith("self."):
                meth_name = meth_name[5:]
            elif meth_name.startswith("self::"):
                meth_name = meth_name[6:]
            # close all classes/modules indented at least as much
            while classstack and classstack[-1][1] >= thisindent:
                if classstack[-1][0] is not None:
                    # record the end line
                    classstack[-1][0].setEndLine(lineno - 1)
                del classstack[-1]
            while acstack and acstack[-1][1] >= thisindent:
                del acstack[-1]
            if classstack:
                # it's a class/module method
                cur_class = classstack[-1][0]
                if isinstance(cur_class, (Class, Module)):
                    # it's a method
                    f = Function(None, meth_name, file, lineno, meth_sig)
                    cur_class._addmethod(meth_name, f)
                else:
                    f = cur_class
                # set access control
                if acstack:
                    accesscontrol = acstack[-1][0]
                    if accesscontrol == "private":
                        f.setPrivate()
                    elif accesscontrol == "protected":
                        f.setProtected()
                    elif accesscontrol == "public":
                        f.setPublic()
                # else it's a nested def
            else:
                # it's a function
                f = Function(module, meth_name, file, lineno, meth_sig)
                if meth_name in dict_counts:
                    dict_counts[meth_name] += 1
                    meth_name = "{0}_{1:d}".format(meth_name, dict_counts[meth_name])
                else:
                    dict_counts[meth_name] = 0
                dictionary[meth_name] = f
            if not classstack:
                if lastGlobalEntry:
                    lastGlobalEntry.setEndLine(lineno - 1)
                lastGlobalEntry = f
            if cur_obj and isinstance(cur_obj, Function):
                cur_obj.setEndLine(lineno - 1)
            cur_obj = f
            classstack.append((f, thisindent))  # Marker for nested fns

        elif (
            m.hasCaptured("String")
            or m.hasCaptured("Comment")
            or m.hasCaptured("ClassIgnored")
            or m.hasCaptured("BeginEnd")
        ):
            pass

        elif m.hasCaptured("Class"):
            # we found a class definition
            thisindent = indent
            indent += 1
            lineno += src.count("\n", last_lineno_pos, start)
            last_lineno_pos = start
            # close all classes/modules indented at least as much
            while classstack and classstack[-1][1] >= thisindent:
                if classstack[-1][0] is not None:
                    # record the end line
                    classstack[-1][0].setEndLine(lineno - 1)
                del classstack[-1]
            class_name = m.captured("ClassName") or m.captured("ClassName2")
            inherit = m.captured("ClassSupers")
            if inherit:
                # the class inherits from other classes
                inherit = inherit[1:].strip()
                inherit = [_commentsub("", inherit)]
            # remember this class
            cur_class = Class(module, class_name, inherit, file, lineno)
            if not classstack:
                if class_name in dictionary:
                    cur_class = dictionary[class_name]
                else:
                    dictionary[class_name] = cur_class
            else:
                cls = classstack[-1][0]
                if class_name in cls.classes:
                    cur_class = cls.classes[class_name]
                elif class_name in (cls.name, "self"):
                    cur_class = cls
                else:
                    cls._addclass(class_name, cur_class)
            if not classstack:
                if lastGlobalEntry:
                    lastGlobalEntry.setEndLine(lineno - 1)
                lastGlobalEntry = cur_class
            cur_obj = cur_class
            classstack.append((cur_class, thisindent))
            while acstack and acstack[-1][1] >= thisindent:
                del acstack[-1]
            acstack.append(["public", thisindent])
            # default access control is 'public'

        elif m.hasCaptured("Module"):
            # we found a module definition
            thisindent = indent
            indent += 1
            lineno += src.count("\n", last_lineno_pos, start)
            last_lineno_pos = start
            # close all classes/modules indented at least as much
            while classstack and classstack[-1][1] >= thisindent:
                if classstack[-1][0] is not None:
                    # record the end line
                    classstack[-1][0].setEndLine(lineno - 1)
                del classstack[-1]
            module_name = m.captured("ModuleName")
            # remember this class
            cur_class = Module(module, module_name, file, lineno)
            if not classstack:
                if module_name in dictionary:
                    cur_class = dictionary[module_name]
                else:
                    dictionary[module_name] = cur_class
            else:
                cls = classstack[-1][0]
                if module_name in cls.classes:
                    cur_class = cls.classes[module_name]
                elif cls.name == module_name:
                    cur_class = cls
                else:
                    cls._addclass(module_name, cur_class)
            if not classstack:
                if lastGlobalEntry:
                    lastGlobalEntry.setEndLine(lineno - 1)
                lastGlobalEntry = cur_class
            cur_obj = cur_class
            classstack.append((cur_class, thisindent))
            while acstack and acstack[-1][1] >= thisindent:
                del acstack[-1]
            acstack.append(["public", thisindent])
            # default access control is 'public'

        elif m.hasCaptured("AccessControl"):
            aclist = m.captured("AccessControlList")
            if not aclist:
                index = -1
                while index >= -len(acstack):
                    if acstack[index][1] < indent:
                        actype = (
                            m.captured("AccessControlType")
                            or m.captured("AccessControlType2").split("_")[0]
                        )
                        acstack[index][0] = actype.lower()
                        break
                    else:
                        index -= 1
            else:
                index = -1
                while index >= -len(classstack):
                    if (
                        classstack[index][0] is not None
                        and not isinstance(classstack[index][0], Function)
                        and classstack[index][1] < indent
                    ):
                        parent = classstack[index][0]
                        actype = (
                            m.captured("AccessControlType")
                            or m.captured("AccessControlType2").split("_")[0]
                        )
                        actype = actype.lower()
                        for name in aclist.split(","):
                            name = name.strip()[1:]  # get rid of leading ':'
                            acmeth = parent._getmethod(name)
                            if acmeth is None:
                                continue
                            if actype == "private":
                                acmeth.setPrivate()
                            elif actype == "protected":
                                acmeth.setProtected()
                            elif actype == "public":
                                acmeth.setPublic()
                        break
                    else:
                        index -= 1

        elif m.hasCaptured("Attribute"):
            lineno += src.count("\n", last_lineno_pos, start)
            last_lineno_pos = start
            index = -1
            while index >= -len(classstack):
                if (
                    classstack[index][0] is not None
                    and not isinstance(classstack[index][0], Function)
                    and classstack[index][1] < indent
                ):
                    attr = Attribute(module, m.captured("AttributeName"), file, lineno)
                    classstack[index][0]._addattribute(attr)
                    break
                else:
                    index -= 1
                    if lastGlobalEntry:
                        lastGlobalEntry.setEndLine(lineno - 1)
                    lastGlobalEntry = None

        elif m.hasCaptured("Attr"):
            lineno += src.count("\n", last_lineno_pos, start)
            last_lineno_pos = start
            index = -1
            while index >= -len(classstack):
                if (
                    classstack[index][0] is not None
                    and not isinstance(classstack[index][0], Function)
                    and classstack[index][1] < indent
                ):
                    parent = classstack[index][0]
                    if not m.captured("AttrType"):
                        nv = m.captured("AttrList").split(",")
                        if not nv:
                            break
                        name = nv[0].strip()[1:]  # get rid of leading ':'
                        attr = (
                            parent._getattribute("@" + name)
                            or parent._getattribute("@@" + name)
                            or Attribute(module, "@" + name, file, lineno)
                        )
                        if len(nv) == 1 or nv[1].strip() == "false":
                            attr.setProtected()
                        elif nv[1].strip() == "true":
                            attr.setPublic()
                        parent._addattribute(attr)
                    else:
                        access = m.captured("AttrType")
                        for name in m.captured("AttrList").split(","):
                            name = name.strip()[1:]  # get rid of leading ':'
                            attr = (
                                parent._getattribute("@" + name)
                                or parent._getattribute("@@" + name)
                                or Attribute(module, "@" + name, file, lineno)
                            )
                            if access == "_accessor":
                                attr.setPublic()
                            elif access in ("_reader", "_writer"):
                                if attr.isPrivate():
                                    attr.setProtected()
                                elif attr.isProtected():
                                    attr.setPublic()
                            parent._addattribute(attr)
                    break
                else:
                    index -= 1

        elif m.hasCaptured("Begin"):
            # a begin of a block we are not interested in
            indent += 1

        elif m.hasCaptured("End"):
            # an end of a block
            indent -= 1
            if indent < 0:
                # no negative indent allowed
                if classstack:
                    # it's a class/module method
                    indent = classstack[-1][1]
                else:
                    indent = 0

        elif m.hasCaptured("CodingLine"):
            # a coding statement
            coding = m.captured("Coding")
            lineno += src.count("\n", last_lineno_pos, start)
            last_lineno_pos = start
            if "@@Coding@@" not in dictionary:
                dictionary["@@Coding@@"] = ClbrBaseClasses.Coding(
                    module, file, lineno, coding
                )

    return dictionary
