"""Function locator using AST parsing."""

import ast
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Set, Tuple


@dataclass(frozen=True)
class FunctionInfo:
    """Information about a Python function."""

    name: str
    lineno: int  # Starting line number (1-indexed)
    end_lineno: Optional[int]  # Ending line number (1-indexed, None if not available)
    source: str  # Full function source code
    is_method: bool  # True if it's a method (inside a class)
    parent_class: Optional[str]  # Name of parent class if it's a method

    def __str__(self) -> str:
        if self.is_method:
            return f"{self.parent_class}.{self.name} (lines {self.lineno}-{self.end_lineno or '?'})"
        return f"{self.name} (lines {self.lineno}-{self.end_lineno or '?'})"


class FunctionLocator:
    """Locates functions in Python files using AST parsing."""

    def __init__(self, file_path: Path):
        """
        Initialize function locator.

        Args:
            file_path: Path to the Python file to parse.
        """
        self.file_path = Path(file_path)
        if not self.file_path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")

    def get_file_imports(self) -> List[str]:
        """
        Extract all import statements from the file.

        Returns:
            List of import statement strings.
        """
        try:
            with open(self.file_path, "r", encoding="utf-8") as f:
                source_code = f.read()
                tree = ast.parse(source_code, filename=str(self.file_path))
        except SyntaxError as e:
            raise SyntaxError(f"Failed to parse {self.file_path}: {e}") from e
        except Exception as e:
            raise RuntimeError(f"Failed to read {self.file_path}: {e}") from e

        imports: List[str] = []
        source_lines = source_code.split("\n")

        for node in ast.walk(tree):
            if isinstance(node, (ast.Import, ast.ImportFrom)):
                # Get the line number and extract the import statement
                lineno = node.lineno
                if lineno <= len(source_lines):
                    import_line = source_lines[lineno - 1].strip()
                    if import_line.startswith(("import ", "from ")):
                        imports.append(import_line)

        return imports

    def get_all_functions(self) -> List[FunctionInfo]:
        """
        Extract all function definitions from the file.

        Returns:
            List of FunctionInfo objects for all functions in the file.
        """
        try:
            with open(self.file_path, "r", encoding="utf-8") as f:
                source_code = f.read()
                tree = ast.parse(source_code, filename=str(self.file_path))
        except SyntaxError as e:
            raise SyntaxError(f"Failed to parse {self.file_path}: {e}") from e
        except Exception as e:
            raise RuntimeError(f"Failed to read {self.file_path}: {e}") from e

        functions: List[FunctionInfo] = []
        visitor = FunctionVisitor(self.file_path, source_code)
        visitor.visit(tree)
        return visitor.functions

    def get_changed_functions(self, changed_ranges: List[Tuple[int, int]]) -> List[FunctionInfo]:
        """
        Get functions that intersect with the given changed line ranges.

        Args:
            changed_ranges: List of (start_line, end_line) tuples representing
                          changed line ranges.

        Returns:
            List of FunctionInfo objects for functions that were changed.
        """
        all_functions = self.get_all_functions()
        changed_functions: List[FunctionInfo] = []

        # Create a set of all changed line numbers for quick lookup
        changed_lines: Set[int] = set()
        for start, end in changed_ranges:
            changed_lines.update(range(start, end + 1))

        for func in all_functions:
            # Check if any changed line falls within this function's range
            func_start = func.lineno
            func_end = func.end_lineno if func.end_lineno else func_start

            # Check if changed lines overlap with function lines
            if any(func_start <= line <= func_end for line in changed_lines) or any(
                func_start <= start <= func_end or func_start <= end <= func_end
                for start, end in changed_ranges
            ):
                changed_functions.append(func)

        return changed_functions


class FunctionVisitor(ast.NodeVisitor):
    """AST visitor to extract function definitions."""

    def __init__(self, file_path: Path, source_code: str):
        """
        Initialize the visitor.

        Args:
            file_path: Path to the source file.
            source_code: Full source code of the file.
        """
        self.file_path = file_path
        self.source_code = source_code
        self.functions: List[FunctionInfo] = []
        self.current_class: Optional[str] = None

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """Visit a function definition node."""
        self._extract_function(node, is_async=False)
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        """Visit an async function definition node."""
        self._extract_function(node, is_async=True)
        self.generic_visit(node)

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        """Visit a class definition node to track current class context."""
        old_class = self.current_class
        self.current_class = node.name
        self.generic_visit(node)
        self.current_class = old_class

    def _extract_function(
        self, node: ast.FunctionDef | ast.AsyncFunctionDef, is_async: bool
    ) -> None:
        """
        Extract function information from an AST node.

        Args:
            node: Function definition AST node.
            is_async: Whether the function is async.
        """
        # Get line numbers
        lineno = node.lineno
        end_lineno = getattr(node, "end_lineno", None)

        # Extract source code
        source_lines = self.source_code.split("\n")
        if end_lineno:
            # Extract from lineno to end_lineno (both 1-indexed)
            function_source = "\n".join(source_lines[lineno - 1 : end_lineno])
        else:
            # Fallback: try to find the end by looking for dedent
            # This is a simple heuristic - find the next line with same or less indentation
            start_line = source_lines[lineno - 1]
            base_indent = len(start_line) - len(start_line.lstrip())
            function_source = start_line

            # Find where function ends (next line with <= indentation that's not empty)
            for i in range(lineno, len(source_lines)):
                line = source_lines[i]
                if line.strip():  # Non-empty line
                    line_indent = len(line) - len(line.lstrip())
                    if line_indent <= base_indent:
                        break
                    function_source += "\n" + line
                else:
                    function_source += "\n" + line

        # Determine if it's a method
        is_method = self.current_class is not None

        func_info = FunctionInfo(
            name=node.name,
            lineno=lineno,
            end_lineno=end_lineno,
            source=function_source,
            is_method=is_method,
            parent_class=self.current_class,
        )

        self.functions.append(func_info)
