# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/coveragepy/coveragepy/blob/main/NOTICE.txt

"""Find functions and classes in Python code."""

from __future__ import annotations

import ast
from typing import cast
from dataclasses import dataclass

from coverage.plugin import CodeRegion


@dataclass
class Context:
    """The nested named context of a function or class."""

    name: str
    kind: str
    lines: set[int]


class RegionFinder:
    """An ast visitor that will find and track regions of code.

    Functions and classes are tracked by name. Results are in the .regions
    attribute.

    """

    def __init__(self) -> None:
        self.regions: list[CodeRegion] = []
        self.context: list[Context] = []

    def parse_source(self, source: str) -> None:
        """Parse `source` and walk the ast to populate the .regions attribute."""
        self.handle_node(ast.parse(source))

    def fq_node_name(self) -> str:
        """Get the current fully qualified name we're processing."""
        return ".".join(c.name for c in self.context)

    def handle_node(self, node: ast.AST) -> None:
        """Recursively handle any node."""
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            self.handle_FunctionDef(node)
        elif isinstance(node, ast.ClassDef):
            self.handle_ClassDef(node)
        else:
            self.handle_node_body(node)

    def handle_node_body(self, node: ast.AST) -> None:
        """Recursively handle the nodes in this node's body, if any."""
        for body_node in getattr(node, "body", ()):
            self.handle_node(body_node)

    def handle_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
        """Called for `def` or `async def`."""
        lines = set(range(node.body[0].lineno, cast(int, node.body[-1].end_lineno) + 1))
        if self.context and self.context[-1].kind == "class":
            # Function bodies are part of their enclosing class.
            self.context[-1].lines |= lines
        # Function bodies should be excluded from the nearest enclosing function.
        for ancestor in reversed(self.context):
            if ancestor.kind == "function":
                ancestor.lines -= lines
                break
        self.context.append(Context(node.name, "function", lines))
        self.regions.append(
            CodeRegion(
                kind="function",
                name=self.fq_node_name(),
                start=node.lineno,
                lines=lines,
            )
        )
        self.handle_node_body(node)
        self.context.pop()

    def handle_ClassDef(self, node: ast.ClassDef) -> None:
        """Called for `class`."""
        # The lines for a class are the lines in the methods of the class.
        # We start empty, and count on visit_FunctionDef to add the lines it
        # finds.
        lines: set[int] = set()
        self.context.append(Context(node.name, "class", lines))
        self.regions.append(
            CodeRegion(
                kind="class",
                name=self.fq_node_name(),
                start=node.lineno,
                lines=lines,
            )
        )
        self.handle_node_body(node)
        self.context.pop()
        # Class bodies should be excluded from the enclosing classes.
        for ancestor in reversed(self.context):
            if ancestor.kind == "class":
                ancestor.lines -= lines


def code_regions(source: str) -> list[CodeRegion]:
    """Find function and class regions in source code.

    Analyzes the code in `source`, and returns a list of :class:`CodeRegion`
    objects describing functions and classes as regions of the code::

        [
            CodeRegion(kind="function", name="func1", start=8, lines={10, 11, 12}),
            CodeRegion(kind="function", name="MyClass.method", start=30, lines={34, 35, 36}),
            CodeRegion(kind="class", name="MyClass", start=25, lines={34, 35, 36}),
        ]

    The line numbers will include comments and blank lines.  Later processing
    will need to ignore those lines as needed.

    Nested functions and classes are excluded from their enclosing region.  No
    line should be reported as being part of more than one function, or more
    than one class.  Lines in methods are reported as being in a function and
    in a class.

    """
    rf = RegionFinder()
    rf.parse_source(source)
    return rf.regions
