"""AST-based code analysis for static analysis.

Provides AST inspection capabilities for detecting code patterns,
extracting structure, and supporting fix generation.
"""

from __future__ import annotations

import ast
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple, Union


# H-121: File-level analysis cache — skip re-analysis if the file hasn't changed.
# Keyed by filepath, stores (mtime, ASTAnalysisResult).
_analysis_cache: Dict[str, Tuple[float, Any]] = {}


def analyze_file_cached(file_path: str) -> "ASTAnalysisResult":
    """Analyze a Python file, returning cached results if unchanged.

    Uses file mtime to detect changes. If the file hasn't been modified
    since the last analysis, the cached result is returned immediately.

    Args:
        file_path: Absolute or relative path to a Python source file

    Returns:
        ASTAnalysisResult from analysis
    """
    try:
        mtime = os.path.getmtime(file_path)
    except OSError:
        # File doesn't exist or is inaccessible — fall through to fresh analysis
        mtime = 0.0

    cached = _analysis_cache.get(file_path)
    if cached is not None:
        cached_mtime, cached_result = cached
        if cached_mtime == mtime and mtime != 0.0:
            return cached_result

    with open(file_path, "r") as f:
        source = f.read()

    analyzer = ASTAnalyzer()
    result = analyzer.analyze(source)

    if mtime != 0.0:
        _analysis_cache[file_path] = (mtime, result)

    return result


@dataclass
class ASTIssue:
    """An issue found during AST analysis.

    Attributes:
        category: Issue category
        message: Description of the issue
        line: Line number
        column: Column number
        node_type: AST node type
        suggestion: Optional fix suggestion
    """

    category: str
    message: str
    line: int
    column: int = 0
    node_type: str = ""
    suggestion: Optional[str] = None


@dataclass
class FunctionInfo:
    """Information about a function definition.

    Attributes:
        name: Function name
        line: Starting line
        end_line: Ending line
        args: Argument names
        return_annotation: Return type annotation
        decorators: List of decorator names
        is_async: Whether function is async
        docstring: Function docstring
        complexity: Cyclomatic complexity estimate
    """

    name: str
    line: int
    end_line: int
    args: List[str] = field(default_factory=list)
    return_annotation: Optional[str] = None
    decorators: List[str] = field(default_factory=list)
    is_async: bool = False
    docstring: Optional[str] = None
    complexity: int = 1


@dataclass
class ClassInfo:
    """Information about a class definition.

    Attributes:
        name: Class name
        line: Starting line
        end_line: Ending line
        bases: Base class names
        methods: Method information
        attributes: Class attribute names
        decorators: Decorator names
    """

    name: str
    line: int
    end_line: int
    bases: List[str] = field(default_factory=list)
    methods: List[FunctionInfo] = field(default_factory=list)
    attributes: List[str] = field(default_factory=list)
    decorators: List[str] = field(default_factory=list)


@dataclass
class ImportInfo:
    """Information about an import statement.

    Attributes:
        module: Module name
        names: Imported names (for 'from' imports)
        alias: Import alias
        line: Line number
        is_from: Whether it's a 'from' import
    """

    module: str
    names: List[str] = field(default_factory=list)
    alias: Optional[str] = None
    line: int = 0
    is_from: bool = False


@dataclass
class ASTAnalysisResult:
    """Complete AST analysis result.

    Attributes:
        functions: Function definitions
        classes: Class definitions
        imports: Import statements
        global_vars: Global variable assignments
        issues: Issues found
        complexity: Total complexity
    """

    functions: List[FunctionInfo] = field(default_factory=list)
    classes: List[ClassInfo] = field(default_factory=list)
    imports: List[ImportInfo] = field(default_factory=list)
    global_vars: List[str] = field(default_factory=list)
    issues: List[ASTIssue] = field(default_factory=list)
    complexity: int = 0


class ASTAnalyzer:
    """AST-based static analyzer for Python code.

    Analyzes Python source code using AST to extract structure
    and detect potential issues.
    """

    def __init__(self):
        """Initialize the AST analyzer."""
        self._tree: Optional[ast.AST] = None
        self._source: str = ""

    def analyze(self, source: str) -> ASTAnalysisResult:
        """Analyze Python source code.

        Args:
            source: Python source code

        Returns:
            ASTAnalysisResult with structure and issues
        """
        self._source = source
        result = ASTAnalysisResult()

        try:
            self._tree = ast.parse(source)
        except SyntaxError as e:
            result.issues.append(
                ASTIssue(
                    category="syntax",
                    message=str(e.msg) if e.msg else str(e),
                    line=e.lineno or 1,
                    column=e.offset or 0,
                )
            )
            return result

        # Extract structure
        result.functions = self._extract_functions(self._tree)
        result.classes = self._extract_classes(self._tree)
        result.imports = self._extract_imports(self._tree)
        result.global_vars = self._extract_global_vars(self._tree)

        # Calculate complexity
        result.complexity = self._calculate_complexity(self._tree)

        # Detect issues
        result.issues = self._detect_issues(self._tree)

        return result

    def _extract_functions(self, tree: ast.AST) -> List[FunctionInfo]:
        """Extract function definitions from AST.

        Args:
            tree: AST tree

        Returns:
            List of FunctionInfo
        """
        functions = []

        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                # Skip methods (handled in class extraction)
                if self._is_method(node, tree):
                    continue

                func_info = self._extract_function_info(node)
                functions.append(func_info)

        return functions

    def _extract_function_info(
        self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
    ) -> FunctionInfo:
        """Extract info from a function node.

        Args:
            node: Function AST node

        Returns:
            FunctionInfo
        """
        args = [arg.arg for arg in node.args.args]

        return_annotation = None
        if node.returns:
            return_annotation = ast.unparse(node.returns)

        decorators = []
        for dec in node.decorator_list:
            if isinstance(dec, ast.Name):
                decorators.append(dec.id)
            elif isinstance(dec, ast.Attribute):
                decorators.append(ast.unparse(dec))
            elif isinstance(dec, ast.Call):
                if isinstance(dec.func, ast.Name):
                    decorators.append(dec.func.id)
                elif isinstance(dec.func, ast.Attribute):
                    decorators.append(ast.unparse(dec.func))

        docstring = ast.get_docstring(node)

        return FunctionInfo(
            name=node.name,
            line=node.lineno,
            end_line=node.end_lineno or node.lineno,
            args=args,
            return_annotation=return_annotation,
            decorators=decorators,
            is_async=isinstance(node, ast.AsyncFunctionDef),
            docstring=docstring,
            complexity=self._calculate_complexity(node),
        )

    def _extract_classes(self, tree: ast.AST) -> List[ClassInfo]:
        """Extract class definitions from AST.

        Args:
            tree: AST tree

        Returns:
            List of ClassInfo
        """
        classes = []

        for node in ast.walk(tree):
            if isinstance(node, ast.ClassDef):
                class_info = self._extract_class_info(node)
                classes.append(class_info)

        return classes

    def _extract_class_info(self, node: ast.ClassDef) -> ClassInfo:
        """Extract info from a class node.

        Args:
            node: Class AST node

        Returns:
            ClassInfo
        """
        bases = []
        for base in node.bases:
            if isinstance(base, ast.Name):
                bases.append(base.id)
            elif isinstance(base, ast.Attribute):
                bases.append(ast.unparse(base))

        methods = []
        attributes = []

        for item in node.body:
            if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
                methods.append(self._extract_function_info(item))
            elif isinstance(item, ast.Assign):
                for target in item.targets:
                    if isinstance(target, ast.Name):
                        attributes.append(target.id)
            elif isinstance(item, ast.AnnAssign):
                if isinstance(item.target, ast.Name):
                    attributes.append(item.target.id)

        decorators = []
        for dec in node.decorator_list:
            if isinstance(dec, ast.Name):
                decorators.append(dec.id)
            elif isinstance(dec, ast.Attribute):
                decorators.append(ast.unparse(dec))

        return ClassInfo(
            name=node.name,
            line=node.lineno,
            end_line=node.end_lineno or node.lineno,
            bases=bases,
            methods=methods,
            attributes=attributes,
            decorators=decorators,
        )

    def _extract_imports(self, tree: ast.AST) -> List[ImportInfo]:
        """Extract import statements from AST.

        Args:
            tree: AST tree

        Returns:
            List of ImportInfo
        """
        imports = []

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    imports.append(
                        ImportInfo(
                            module=alias.name,
                            alias=alias.asname,
                            line=node.lineno,
                            is_from=False,
                        )
                    )
            elif isinstance(node, ast.ImportFrom):
                names = [alias.name for alias in node.names]
                imports.append(
                    ImportInfo(
                        module=node.module or "",
                        names=names,
                        line=node.lineno,
                        is_from=True,
                    )
                )

        return imports

    def _extract_global_vars(self, tree: ast.AST) -> List[str]:
        """Extract global variable assignments.

        Args:
            tree: AST tree

        Returns:
            List of variable names
        """
        global_vars = []

        for node in ast.iter_child_nodes(tree):
            if isinstance(node, ast.Assign):
                for target in node.targets:
                    if isinstance(target, ast.Name):
                        global_vars.append(target.id)
            elif isinstance(node, ast.AnnAssign):
                if isinstance(node.target, ast.Name):
                    global_vars.append(node.target.id)

        return global_vars

    def _calculate_complexity(self, node: ast.AST) -> int:
        """Calculate cyclomatic complexity.

        Args:
            node: AST node

        Returns:
            Complexity score
        """
        complexity = 1

        for child in ast.walk(node):
            # Decision points (standard McCabe cyclomatic complexity)
            if isinstance(child, (ast.If, ast.While, ast.For, ast.AsyncFor)):
                complexity += 1
            elif isinstance(child, ast.ExceptHandler):
                complexity += 1
            elif isinstance(child, ast.comprehension):
                complexity += 1
            elif isinstance(child, ast.BoolOp):
                # Each 'and' or 'or' adds to complexity
                complexity += len(child.values) - 1
            elif isinstance(child, ast.IfExp):
                complexity += 1
            # Python 3.10+ match/case (each case arm is a decision point)
            elif hasattr(ast, 'match_case') and isinstance(child, ast.match_case):
                complexity += 1

        return complexity

    def _detect_issues(self, tree: ast.AST) -> List[ASTIssue]:
        """Detect potential issues in AST.

        Args:
            tree: AST tree

        Returns:
            List of issues found
        """
        issues = []

        for node in ast.walk(tree):
            # Bare except
            if isinstance(node, ast.ExceptHandler):
                if node.type is None:
                    issues.append(
                        ASTIssue(
                            category="bare_except",
                            message="Bare except clause catches all exceptions",
                            line=node.lineno,
                            node_type="ExceptHandler",
                            suggestion="Catch a specific exception type",
                        )
                    )

            # Broad exception
            if isinstance(node, ast.ExceptHandler):
                if isinstance(node.type, ast.Name) and node.type.id in (
                    "Exception",
                    "BaseException",
                ):
                    # Check if it just passes
                    if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
                        issues.append(
                            ASTIssue(
                                category="silent_exception",
                                message="Exception caught and silently ignored",
                                line=node.lineno,
                                node_type="ExceptHandler",
                                suggestion="Log or handle the exception",
                            )
                        )

            # Unused variable detection (function-scope two-pass)
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                issues.extend(self._detect_unused_variables(node))

            # Mutable default argument
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                for default in node.args.defaults + node.args.kw_defaults:
                    if default and isinstance(default, (ast.List, ast.Dict, ast.Set)):
                        issues.append(
                            ASTIssue(
                                category="mutable_default",
                                message="Mutable default argument",
                                line=node.lineno,
                                node_type=type(node).__name__,
                                suggestion="Use None and create inside function",
                            )
                        )

            # Assert with tuple (always True)
            if isinstance(node, ast.Assert):
                if isinstance(node.test, ast.Tuple):
                    issues.append(
                        ASTIssue(
                            category="assert_tuple",
                            message="Assert with tuple is always True",
                            line=node.lineno,
                            node_type="Assert",
                            suggestion="Remove tuple parentheses",
                        )
                    )

        return issues

    def _detect_unused_variables(
        self,
        func_node: Union[ast.FunctionDef, ast.AsyncFunctionDef],
    ) -> List[ASTIssue]:
        """Detect variables assigned but never used within a function.

        Uses NodeVisitor to avoid descending into nested function/class scopes.
        """
        issues = []
        stores: Dict[str, int] = {}
        loads: set = set()

        class _ScopeAwareCollector(ast.NodeVisitor):
            """Collect stores/loads without descending into nested scopes."""
            def __init__(self):
                self.depth = 0

            def visit_FunctionDef(self, node):
                if self.depth > 0:
                    return  # Don't descend into nested functions
                self.depth += 1
                self.generic_visit(node)
                self.depth -= 1

            def visit_AsyncFunctionDef(self, node):
                if self.depth > 0:
                    return
                self.depth += 1
                self.generic_visit(node)
                self.depth -= 1

            def visit_ClassDef(self, node):
                return  # Don't descend into nested classes

            def visit_Name(self, node):
                if isinstance(node.ctx, ast.Store):
                    if not node.id.startswith("_"):
                        stores.setdefault(node.id, node.lineno)
                elif isinstance(node.ctx, (ast.Load, ast.Del)):
                    loads.add(node.id)

        _ScopeAwareCollector().visit(func_node)

        # Report names that are stored but never loaded
        for name, line in stores.items():
            if name not in loads:
                issues.append(
                    ASTIssue(
                        category="unused_variable",
                        message=f"Variable '{name}' is assigned but never used",
                        line=line,
                        node_type="Assign",
                        suggestion="Remove the variable or prefix with _ to indicate intentional disuse",
                    )
                )

        return issues

    def _is_method(
        self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef], tree: ast.AST
    ) -> bool:
        """Check if function is a method inside a class.

        Uses a parent map for O(1) lookup instead of O(N) tree walk.

        Args:
            node: Function node
            tree: Full AST tree

        Returns:
            True if node is a method
        """
        # Build parent map lazily and cache it
        if not hasattr(self, '_parent_map') or self._parent_map is None:
            self._parent_map = {}
            for parent_node in ast.walk(tree):
                for child in ast.iter_child_nodes(parent_node):
                    self._parent_map[id(child)] = parent_node
        parent = self._parent_map.get(id(node))
        return isinstance(parent, ast.ClassDef)

    def find_function_at_line(self, line: int) -> Optional[FunctionInfo]:
        """Find function containing a specific line.

        Args:
            line: Line number

        Returns:
            FunctionInfo if found
        """
        if not self._tree:
            return None

        # Use cached result to avoid re-parsing on every call
        if not hasattr(self, '_cached_result') or self._cached_result is None:
            self._cached_result = self.analyze(self._source)
        result = self._cached_result
        for func in result.functions:
            if func.line <= line <= func.end_line:
                return func

        for cls in result.classes:
            for method in cls.methods:
                if method.line <= line <= method.end_line:
                    return method

        return None

    def find_class_at_line(self, line: int) -> Optional[ClassInfo]:
        """Find class containing a specific line.

        Args:
            line: Line number

        Returns:
            ClassInfo if found
        """
        if not self._tree:
            return None

        if not hasattr(self, '_cached_result') or self._cached_result is None:
            self._cached_result = self.analyze(self._source)
        result = self._cached_result
        for cls in result.classes:
            if cls.line <= line <= cls.end_line:
                return cls

        return None

    def get_names_in_scope(self, line: int) -> Set[str]:
        """Get all names available at a specific line.

        Args:
            line: Line number

        Returns:
            Set of available names
        """
        if not self._tree:
            return set()

        if not hasattr(self, '_cached_result') or self._cached_result is None:
            self._cached_result = self.analyze(self._source)
        result = self._cached_result
        names: Set[str] = set()

        # Global imports and vars
        for imp in result.imports:
            if imp.line < line:
                if imp.is_from:
                    names.update(imp.names)
                else:
                    names.add(imp.module.split(".")[0])
        names.update(result.global_vars)

        # Function arguments and local vars
        func = self.find_function_at_line(line)
        if func:
            names.update(func.args)
            # Collect local variable assignments before the target line
            func_node = None
            for node in ast.walk(self._tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    if node.lineno == func.line:
                        func_node = node
                        break
            if func_node:
                # Use a scope-aware walk that skips nested functions/classes,
                # matching the pattern used in _detect_unused_variables.
                class _LocalVarCollector(ast.NodeVisitor):
                    def __init__(self):
                        self.depth = 0

                    def visit_FunctionDef(self, node):
                        if self.depth > 0:
                            return  # Skip nested functions
                        self.depth += 1
                        self.generic_visit(node)
                        self.depth -= 1

                    def visit_AsyncFunctionDef(self, node):
                        if self.depth > 0:
                            return  # Skip nested async functions
                        self.depth += 1
                        self.generic_visit(node)
                        self.depth -= 1

                    def visit_ClassDef(self, node):
                        return  # Skip nested classes

                    def visit_Name(self, node):
                        if isinstance(node.ctx, ast.Store) and node.lineno < line:
                            names.add(node.id)

                _LocalVarCollector().visit(func_node)

        # Class attributes
        cls = self.find_class_at_line(line)
        if cls:
            names.update(cls.attributes)
            names.update(m.name for m in cls.methods)

        return names


def analyze_code(source: str) -> ASTAnalysisResult:
    """Convenience function to analyze code.

    Args:
        source: Python source code

    Returns:
        ASTAnalysisResult
    """
    analyzer = ASTAnalyzer()
    return analyzer.analyze(source)


__all__ = [
    "ASTIssue",
    "FunctionInfo",
    "ClassInfo",
    "ImportInfo",
    "ASTAnalysisResult",
    "ASTAnalyzer",
    "analyze_code",
    "analyze_file_cached",
]
