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

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

"""
Module implementing a checker for function type annotations.
"""

import ast
import contextlib
import copy
import sys

from functools import lru_cache

import AstUtilities

from .AnnotationsCheckerDefaults import AnnotationsCheckerDefaultArgs
from .AnnotationsEnums import AnnotationType, ClassDecoratorType, FunctionType


class AnnotationsChecker:
    """
    Class implementing a checker for function type annotations.
    """

    Codes = [
        ## Function Annotations
        "A001",
        "A002",
        "A003",
        ## Method Annotations
        "A101",
        "A102",
        ## Return Annotations
        "A201",
        "A202",
        "A203",
        "A204",
        "A205",
        "A206",
        ## Mixed kind of annotations
        "A301",
        ## Dynamically typed annotations
        "A401",
        ## Annotations Future
        "A871",
        "A872",
        ## Annotation Coverage
        "A881",
        ## Annotation Complexity
        "A891",
        "A892",
    ]

    def __init__(self, source, filename, tree, select, ignore, expected, repeat, args):
        """
        Constructor

        @param source source code to be checked
        @type list of str
        @param filename name of the source file
        @type str
        @param tree AST tree of the source code
        @type ast.Module
        @param select list of selected codes
        @type list of str
        @param ignore list of codes to be ignored
        @type list of str
        @param expected list of expected codes
        @type list of str
        @param repeat flag indicating to report each occurrence of a code
        @type bool
        @param args dictionary of arguments for the annotation checks
        @type dict
        """
        self.__select = tuple(select)
        self.__ignore = ("",) if select else tuple(ignore)
        self.__expected = expected[:]
        self.__repeat = repeat
        self.__filename = filename
        self.__source = source[:]
        self.__tree = copy.deepcopy(tree)
        self.__args = args

        # statistics counters
        self.counters = {}

        # collection of detected errors
        self.errors = []

        checkersWithCodes = [
            (
                self.__checkFunctionAnnotations,
                (
                    "A001",
                    "A002",
                    "A003",
                    "A101",
                    "A102",
                    "A201",
                    "A202",
                    "A203",
                    "A204",
                    "A205",
                    "A206",
                    "A301",
                    "A401",
                ),
            ),
            (self.__checkAnnotationsFuture, ("A871", "A872")),
            (self.__checkAnnotationsCoverage, ("A881",)),
            (self.__checkAnnotationComplexity, ("A891", "A892")),
        ]

        self.__checkers = []
        for checker, codes in checkersWithCodes:
            if any(not (code and self.__ignoreCode(code)) for code in codes):
                self.__checkers.append(checker)

    def __ignoreCode(self, code):
        """
        Private method to check if the message code should be ignored.

        @param code message code to check for
        @type str
        @return flag indicating to ignore the given code
        @rtype bool
        """
        return code.startswith(self.__ignore) and not code.startswith(self.__select)

    def __error(self, lineNumber, offset, code, *args):
        """
        Private method to record an issue.

        @param lineNumber line number of the issue
        @type int
        @param offset position within line of the issue
        @type int
        @param code message code
        @type str
        @param args arguments for the message
        @type list
        """
        if self.__ignoreCode(code):
            return

        if code in self.counters:
            self.counters[code] += 1
        else:
            self.counters[code] = 1

        # Don't care about expected codes
        if code in self.__expected:
            return

        if code and (self.counters[code] == 1 or self.__repeat):
            # record the issue with one based line number
            self.errors.append(
                {
                    "file": self.__filename,
                    "line": lineNumber + 1,
                    "offset": offset,
                    "code": code,
                    "args": args,
                }
            )

    def run(self):
        """
        Public method to check the given source against annotation issues.
        """
        if not self.__filename:
            # don't do anything, if essential data is missing
            return

        if not self.__checkers:
            # don't do anything, if no codes were selected
            return

        for check in self.__checkers:
            check()

    #######################################################################
    ## Annotations
    ##
    ## adapted from: flake8-annotations v2.9.0
    #######################################################################

    def __checkFunctionAnnotations(self):
        """
        Private method to check for function annotation issues.
        """
        from .AnnotationsFunctionVisitor import FunctionVisitor

        suppressNoneReturning = self.__args.get(
            "SuppressNoneReturning",
            AnnotationsCheckerDefaultArgs["SuppressNoneReturning"],
        )
        suppressDummyArgs = self.__args.get(
            "SuppressDummyArgs", AnnotationsCheckerDefaultArgs["SuppressDummyArgs"]
        )
        allowUntypedDefs = self.__args.get(
            "AllowUntypedDefs", AnnotationsCheckerDefaultArgs["AllowUntypedDefs"]
        )
        allowUntypedNested = self.__args.get(
            "AllowUntypedNested", AnnotationsCheckerDefaultArgs["AllowUntypedNested"]
        )
        mypyInitReturn = self.__args.get(
            "MypyInitReturn", AnnotationsCheckerDefaultArgs["MypyInitReturn"]
        )
        allowStarArgAny = self.__args.get(
            "AllowStarArgAny", AnnotationsCheckerDefaultArgs["AllowStarArgAny"]
        )

        # Store decorator lists as sets for easier lookup
        dispatchDecorators = set(
            self.__args.get(
                "DispatchDecorators",
                AnnotationsCheckerDefaultArgs["DispatchDecorators"],
            )
        )
        overloadDecorators = set(
            self.__args.get(
                "OverloadDecorators",
                AnnotationsCheckerDefaultArgs["OverloadDecorators"],
            )
        )

        visitor = FunctionVisitor(self.__source)
        visitor.visit(self.__tree)

        # Keep track of the last encountered function decorated by
        # `typing.overload`, if any. Per the `typing` module documentation,
        # a series of overload-decorated definitions must be followed by
        # exactly one non-overload-decorated definition of the same function.
        lastOverloadDecoratedFunctionName = None

        # Iterate over the arguments with missing type hints, by function.
        for function in visitor.functionDefinitions:
            if function.isDynamicallyTyped() and (
                allowUntypedDefs or (function.isNested and allowUntypedNested)
            ):
                # Skip recording errors from dynamically typed functions
                # or nested functions
                continue

            # Skip recording errors for configured dispatch functions, such as
            # (by default) `functools.singledispatch` and
            # `functools.singledispatchmethod`
            if function.hasDecorator(dispatchDecorators):
                continue

            # Create sentinels to check for mixed hint styles
            hasTypeComment = function.hasTypeComment

            has3107Annotation = False
            # PEP 3107 annotations are captured by the return arg

            annotatedArgs = function.getAnnotatedArguments()

            # Iterate over annotated args to detect mixing of type annotations
            # and type comments. Emit this only once per function definition
            for arg in annotatedArgs:
                if arg.hasTypeComment:
                    hasTypeComment = True

                if arg.has3107Annotation:
                    has3107Annotation = True

                if hasTypeComment and has3107Annotation:
                    # Short-circuit check for mixing of type comments &
                    # 3107-style annotations
                    self.__error(function.lineno - 1, function.col_offset, "A301")
                    break

            # Iterate over the annotated args to look for 'typing.Any' annotations
            # We could combine this with the above loop but I'd rather not add even
            # more sentinels unless we'd notice a significant enough performance impact
            for arg in annotatedArgs:
                if arg.isDynamicallyTyped:
                    if allowStarArgAny and arg.annotationType in {
                        AnnotationType.VARARG,
                        AnnotationType.KWARG,
                    }:
                        continue

                    self.__error(function.lineno - 1, function.col_offset, "A401")

            # Before we iterate over the function's missing annotations, check
            # to see if it's the closing function def in a series of
            # `typing.overload` decorated functions.
            if lastOverloadDecoratedFunctionName == function.name:
                continue

            # If it's not, and it is overload decorated, store it for the next
            # iteration
            if function.hasDecorator(overloadDecorators):
                lastOverloadDecoratedFunctionName = function.name

            # Record explicit errors for arguments that are missing annotations
            for arg in function.getMissedAnnotations():
                if arg.argname == "return":
                    # return annotations have multiple possible short-circuit
                    # paths
                    if (
                        suppressNoneReturning
                        and not arg.hasTypeAnnotation
                        and function.hasOnlyNoneReturns
                    ):
                        # Skip recording return errors if the function has only
                        # `None` returns. This includes the case of no returns.
                        continue

                    if (
                        mypyInitReturn
                        and function.isClassMethod
                        and function.name == "__init__"
                        and annotatedArgs
                    ):
                        # Skip recording return errors for `__init__` if at
                        # least one argument is annotated
                        continue

                # If the `suppressDummyArgs` flag is `True`, skip recording
                # errors for any arguments named `_`
                if arg.argname == "_" and suppressDummyArgs:
                    continue

                self.__classifyError(function, arg)

    def __classifyError(self, function, arg):
        """
        Private method to classify the missing type annotation based on the
        Function & Argument metadata.

        For the currently defined rules & program flow, the assumption can be
        made that an argument passed to this method will match a linting error,
        and will only match a single linting error

        This function provides an initial classificaton, then passes relevant
        attributes to cached helper function(s).

        @param function reference to the Function object
        @type Function
        @param arg reference to the Argument object
        @type Argument
        """
        # Check for return type
        # All return "arguments" have an explicitly defined name "return"
        if arg.argname == "return":
            errorCode = self.__returnErrorClassifier(
                function.isClassMethod,
                function.classDecoratorType,
                function.functionType,
            )
        else:
            # Otherwise, classify function argument error
            isFirstArg = arg == function.args[0]
            errorCode = self.__argumentErrorClassifier(
                function.isClassMethod,
                isFirstArg,
                function.classDecoratorType,
                arg.annotationType,
            )

        if errorCode in ("A001", "A002", "A003"):
            self.__error(arg.lineno - 1, arg.col_offset, errorCode, arg.argname)
        else:
            self.__error(arg.lineno - 1, arg.col_offset, errorCode)

    @lru_cache()  # __IGNORE_WARNING_M519__
    def __returnErrorClassifier(self, isClassMethod, classDecoratorType, functionType):
        """
        Private method to classify a return type annotation issue.

        @param isClassMethod flag indicating a classmethod type function
        @type bool
        @param classDecoratorType type of class decorator
        @type ClassDecoratorType
        @param functionType type of function
        @type FunctionType
        @return error code
        @rtype str
        """
        # Decorated class methods (@classmethod, @staticmethod) have a higher
        # priority than the rest
        if isClassMethod:
            if classDecoratorType == ClassDecoratorType.CLASSMETHOD:
                return "A206"
            elif classDecoratorType == ClassDecoratorType.STATICMETHOD:
                return "A205"

        if functionType == FunctionType.SPECIAL:
            return "A204"
        elif functionType == FunctionType.PRIVATE:
            return "A203"
        elif functionType == FunctionType.PROTECTED:
            return "A202"
        else:
            return "A201"

    @lru_cache()  # __IGNORE_WARNING_M519__
    def __argumentErrorClassifier(
        self, isClassMethod, isFirstArg, classDecoratorType, annotationType
    ):
        """
        Private method to classify an argument type annotation issue.

        @param isClassMethod flag indicating a classmethod type function
        @type bool
        @param isFirstArg flag indicating the first argument
        @type bool
        @param classDecoratorType type of class decorator
        @type enums.ClassDecoratorType
        @param annotationType type of annotation
        @type AnnotationType
        @return error code
        @rtype str
        """
        # Check for regular class methods and @classmethod, @staticmethod is
        # deferred to final check
        if isClassMethod and isFirstArg:
            # The first function argument here would be an instance of self or
            # class
            if classDecoratorType == ClassDecoratorType.CLASSMETHOD:
                return "A102"
            elif classDecoratorType != ClassDecoratorType.STATICMETHOD:
                # Regular class method
                return "A101"

        # Check for remaining codes
        if annotationType == AnnotationType.KWARG:
            return "A003"
        elif annotationType == AnnotationType.VARARG:
            return "A002"
        else:
            # Combine PosOnlyArgs, Args, and KwOnlyArgs
            return "A001"

    #######################################################################
    ## Annotations Coverage
    ##
    ## adapted from: flake8-annotations-coverage v0.0.6
    #######################################################################

    def __checkAnnotationsCoverage(self):
        """
        Private method to check for function annotation coverage.
        """
        minAnnotationsCoverage = self.__args.get(
            "MinimumCoverage", AnnotationsCheckerDefaultArgs["MinimumCoverage"]
        )
        if minAnnotationsCoverage == 0:
            # 0 means it is switched off
            return

        functionDefs = [
            f
            for f in ast.walk(self.__tree)
            if isinstance(f, (ast.AsyncFunctionDef, ast.FunctionDef))
        ]
        if not functionDefs:
            # no functions/methods at all
            return

        functionDefAnnotationsInfo = [
            self.__hasTypeAnnotations(f) for f in functionDefs
        ]
        if not bool(functionDefAnnotationsInfo):
            return

        annotationsCoverage = int(
            len(list(filter(None, functionDefAnnotationsInfo)))
            / len(functionDefAnnotationsInfo)
            * 100
        )
        if annotationsCoverage < minAnnotationsCoverage:
            self.__error(0, 0, "A881", annotationsCoverage)

    def __hasTypeAnnotations(self, funcNode):
        """
        Private method to check for type annotations.

        @param funcNode reference to the function definition node to be checked
        @type ast.AsyncFunctionDef or ast.FunctionDef
        @return flag indicating the presence of type annotations
        @rtype bool
        """
        hasReturnAnnotation = funcNode.returns is not None
        hasArgsAnnotations = any(
            a for a in funcNode.args.args if a.annotation is not None
        )
        hasKwargsAnnotations = (
            funcNode.args
            and funcNode.args.kwarg
            and funcNode.args.kwarg.annotation is not None
        )
        hasKwonlyargsAnnotations = any(
            a for a in funcNode.args.kwonlyargs if a.annotation is not None
        )

        return any(
            (
                hasReturnAnnotation,
                hasArgsAnnotations,
                hasKwargsAnnotations,
                hasKwonlyargsAnnotations,
            )
        )

    #######################################################################
    ## Annotations Complexity
    ##
    ## adapted from: flake8-annotations-complexity v0.0.7
    #######################################################################

    def __checkAnnotationComplexity(self):
        """
        Private method to check the type annotation complexity.
        """
        maxAnnotationComplexity = self.__args.get(
            "MaximumComplexity", AnnotationsCheckerDefaultArgs["MaximumComplexity"]
        )
        maxAnnotationLength = self.__args.get(
            "MaximumLength", AnnotationsCheckerDefaultArgs["MaximumLength"]
        )
        typeAnnotations = []

        functionDefs = [
            f
            for f in ast.walk(self.__tree)
            if isinstance(f, (ast.AsyncFunctionDef, ast.FunctionDef))
        ]
        for functionDef in functionDefs:
            typeAnnotations += list(
                filter(None, (a.annotation for a in functionDef.args.args))
            )
            if functionDef.returns:
                typeAnnotations.append(functionDef.returns)
        typeAnnotations += [
            a.annotation
            for a in ast.walk(self.__tree)
            if isinstance(a, ast.AnnAssign) and a.annotation
        ]
        for annotation in typeAnnotations:
            complexity = self.__getAnnotationComplexity(annotation)
            if complexity > maxAnnotationComplexity:
                self.__error(
                    annotation.lineno - 1,
                    annotation.col_offset,
                    "A891",
                    complexity,
                    maxAnnotationComplexity,
                )

            annotationLength = self.__getAnnotationLength(annotation)
            if annotationLength > maxAnnotationLength:
                self.__error(
                    annotation.lineno - 1,
                    annotation.col_offset,
                    "A892",
                    annotationLength,
                    maxAnnotationLength,
                )

    def __getAnnotationComplexity(self, annotationNode, defaultComplexity=1):
        """
        Private method to determine the annotation complexity.

        @param annotationNode reference to the node to determine the annotation
            complexity for
        @type ast.AST
        @param defaultComplexity default complexity value
        @type int
        @return annotation complexity
        @rtype = int
        """
        if AstUtilities.isString(annotationNode):
            try:
                annotationNode = ast.parse(annotationNode.s).body[0].value
            except (SyntaxError, IndexError):
                return defaultComplexity

        complexity = defaultComplexity
        if isinstance(annotationNode, ast.Subscript):
            if sys.version_info >= (3, 9):
                complexity = defaultComplexity + self.__getAnnotationComplexity(
                    annotationNode.slice
                )
            else:
                complexity = defaultComplexity + self.__getAnnotationComplexity(
                    annotationNode.slice.value
                )

        if isinstance(annotationNode, ast.Tuple):
            complexity = max(
                (self.__getAnnotationComplexity(n) for n in annotationNode.elts),
                default=defaultComplexity,
            )

        return complexity

    def __getAnnotationLength(self, annotationNode):
        """
        Private method to determine the annotation length.

        @param annotationNode reference to the node to determine the annotation
            length for
        @type ast.AST
        @return annotation length
        @rtype = int
        """
        annotationLength = 0
        if AstUtilities.isString(annotationNode):
            try:
                annotationNode = ast.parse(annotationNode.s).body[0].value
            except (SyntaxError, IndexError):
                return annotationLength

        if isinstance(annotationNode, ast.Subscript):
            with contextlib.suppress(AttributeError):
                annotationLength = (
                    len(annotationNode.slice.elts)
                    if sys.version_info >= (3, 9)
                    else len(annotationNode.slice.value.elts)
                )

        return annotationLength

    #######################################################################
    ## 'from __future__ import annotations' checck
    ##
    ## adapted from: flake8-future-annotations v0.0.5
    #######################################################################

    def __checkAnnotationsFuture(self):
        """
        Private method to check the use of __future__ and typing imports.
        """
        from .AnnotationsFutureVisitor import AnnotationsFutureVisitor

        forceFutureAnnotations = self.__args.get(
            "ForceFutureAnnotations",
            AnnotationsCheckerDefaultArgs["ForceFutureAnnotations"],
        )

        visitor = AnnotationsFutureVisitor()
        visitor.visit(self.__tree)

        if visitor.importsFutureAnnotations():
            return

        if visitor.hasTypingImports():
            imports = ", ".join(visitor.getTypingImports())
            self.__error(0, 0, "A871", imports)
        elif forceFutureAnnotations:
            self.__error(0, 0, "A872")
