"""Dead code detector for Python.

Identifies:
- Functions/methods defined but never called within the project
- Unused imports
- Unreachable code after return/raise/break/continue
"""

from __future__ import annotations

import ast
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


@dataclass
class DeadCodeItem:
    """A piece of potentially dead code."""

    file_path: str
    name: str
    line_number: int
    kind: str  # "unused_function", "unused_import", "unreachable_code"
    confidence: float = 0.8  # 0.0 - 1.0

    def to_dict(self) -> dict[str, Any]:
        return {
            "file_path": self.file_path,
            "name": self.name,
            "line_number": self.line_number,
            "kind": self.kind,
            "confidence": self.confidence,
        }


class _DefinitionCollector(ast.NodeVisitor):
    """Collect all function/class definitions and imports."""

    def __init__(self) -> None:
        self.functions: list[tuple[str, int]] = []
        self.classes: list[tuple[str, int]] = []
        self.imports: list[tuple[str, str, int]] = []  # (module, name, line)
        self._class_stack: list[str] = []

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        self.classes.append((node.name, node.lineno))
        self._class_stack.append(node.name)
        self.generic_visit(node)
        self._class_stack.pop()

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        # Skip dunder methods and private methods starting with _
        if not node.name.startswith("__"):
            prefix = f"{self._class_stack[-1]}." if self._class_stack else ""
            self.functions.append((f"{prefix}{node.name}", node.lineno))
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        if not node.name.startswith("__"):
            prefix = f"{self._class_stack[-1]}." if self._class_stack else ""
            self.functions.append((f"{prefix}{node.name}", node.lineno))
        self.generic_visit(node)

    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            name = alias.asname or alias.name
            self.imports.append((alias.name, name, node.lineno))

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        module = node.module or ""
        for alias in node.names:
            name = alias.asname or alias.name
            self.imports.append((f"{module}.{alias.name}", name, node.lineno))


class _ReferenceCollector(ast.NodeVisitor):
    """Collect all name references (calls, attribute access, etc.)."""

    def __init__(self) -> None:
        self.referenced_names: set[str] = set()

    def visit_Name(self, node: ast.Name) -> None:
        self.referenced_names.add(node.id)
        self.generic_visit(node)

    def visit_Attribute(self, node: ast.Attribute) -> None:
        self.referenced_names.add(node.attr)
        self.generic_visit(node)

    def visit_Call(self, node: ast.Call) -> None:
        if isinstance(node.func, ast.Name):
            self.referenced_names.add(node.func.id)
        elif isinstance(node.func, ast.Attribute):
            self.referenced_names.add(node.func.attr)
        self.generic_visit(node)


class _UnreachableDetector(ast.NodeVisitor):
    """Detect code after return/raise/break/continue statements."""

    def __init__(self) -> None:
        self.unreachable: list[tuple[int, str]] = []

    def _check_body(self, body: list[ast.stmt]) -> None:
        """Check a list of statements for unreachable code."""
        for i, stmt in enumerate(body):
            if isinstance(stmt, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
                # Check if there are statements after this
                remaining = body[i + 1:]
                for r in remaining:
                    # Skip string expressions (docstrings) and pass
                    if isinstance(r, ast.Expr) and isinstance(r.value, (ast.Constant,)):
                        continue
                    self.unreachable.append(
                        (r.lineno, f"unreachable code after {type(stmt).__name__.lower()}")
                    )
                    break  # Only report the first unreachable statement
                break

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._check_body(node.body)
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._check_body(node.body)
        self.generic_visit(node)

    def visit_If(self, node: ast.If) -> None:
        self._check_body(node.body)
        self._check_body(node.orelse)
        self.generic_visit(node)

    def visit_For(self, node: ast.For) -> None:
        self._check_body(node.body)
        self.generic_visit(node)

    def visit_While(self, node: ast.While) -> None:
        self._check_body(node.body)
        self.generic_visit(node)

    def visit_With(self, node: ast.With) -> None:
        self._check_body(node.body)
        self.generic_visit(node)


class DeadCodeAnalyzer:
    """Detect potentially dead code in Python projects."""

    SUPPORTED_EXTENSIONS = {".py"}
    # Names commonly used via decorators, frameworks, etc.
    EXEMPT_PATTERNS = {
        "setup", "teardown", "setUp", "tearDown",
        "main", "app", "celery_app", "wsgi",
        "register", "handler", "callback",
        "get", "post", "put", "delete", "patch",  # HTTP methods
        "test_",  # Test functions
    }

    def analyze_directory(
        self,
        directory: str | Path,
        exclude_patterns: list[str] | None = None,
    ) -> list[DeadCodeItem]:
        """Find dead code across a Python project."""
        directory = Path(directory)
        exclude_patterns = exclude_patterns or [
            "venv", "node_modules", ".git", "__pycache__", ".tox",
            "build", "dist", "migrations", "tests", "test",
        ]

        # Phase 1: Collect all definitions and references across the project
        all_definitions: dict[str, list[tuple[str, int]]] = defaultdict(list)
        all_imports: dict[str, list[tuple[str, str, int]]] = defaultdict(list)
        global_references: set[str] = set()
        all_unreachable: list[tuple[str, int, str]] = []

        py_files: list[Path] = []
        for py_file in directory.rglob("*.py"):
            parts = py_file.relative_to(directory).parts
            if any(excl in parts for excl in exclude_patterns):
                continue
            py_files.append(py_file)

        for py_file in py_files:
            try:
                source = py_file.read_text(encoding="utf-8", errors="replace")
                tree = ast.parse(source, filename=str(py_file))
            except (OSError, SyntaxError):
                continue

            file_str = str(py_file)

            # Collect definitions
            defn_visitor = _DefinitionCollector()
            defn_visitor.visit(tree)
            for name, lineno in defn_visitor.functions:
                all_definitions[file_str].append((name, lineno))
            for module, name, lineno in defn_visitor.imports:
                all_imports[file_str].append((module, name, lineno))

            # Collect references
            ref_visitor = _ReferenceCollector()
            ref_visitor.visit(tree)
            global_references.update(ref_visitor.referenced_names)

            # Check unreachable code
            unreach = _UnreachableDetector()
            unreach.visit(tree)
            for lineno, desc in unreach.unreachable:
                all_unreachable.append((file_str, lineno, desc))

        # Phase 2: Find unused items
        results: list[DeadCodeItem] = []

        # Unused functions
        for file_path, definitions in all_definitions.items():
            for name, lineno in definitions:
                # Get the simple name (after the last dot)
                simple_name = name.split(".")[-1]
                if self._is_exempt(simple_name):
                    continue
                if simple_name not in global_references:
                    results.append(
                        DeadCodeItem(
                            file_path=file_path,
                            name=name,
                            line_number=lineno,
                            kind="unused_function",
                            confidence=0.7,  # Could be used dynamically
                        )
                    )

        # Unused imports
        for file_path, imports in all_imports.items():
            for module, name, lineno in imports:
                if name == "*":
                    continue
                simple = name.split(".")[-1]
                if simple not in global_references:
                    results.append(
                        DeadCodeItem(
                            file_path=file_path,
                            name=f"import {name} (from {module})",
                            line_number=lineno,
                            kind="unused_import",
                            confidence=0.9,
                        )
                    )

        # Unreachable code
        for file_path, lineno, desc in all_unreachable:
            results.append(
                DeadCodeItem(
                    file_path=file_path,
                    name=desc,
                    line_number=lineno,
                    kind="unreachable_code",
                    confidence=0.95,
                )
            )

        return results

    def _is_exempt(self, name: str) -> bool:
        """Check if a name is likely used by frameworks/decorators."""
        for pattern in self.EXEMPT_PATTERNS:
            if name.startswith(pattern) or name == pattern:
                return True
        return False

    def summary(self, items: list[DeadCodeItem]) -> dict[str, Any]:
        """Generate dead code summary."""
        by_kind: dict[str, int] = defaultdict(int)
        for item in items:
            by_kind[item.kind] += 1

        return {
            "total_dead_code_items": len(items),
            "unused_functions": by_kind.get("unused_function", 0),
            "unused_imports": by_kind.get("unused_import", 0),
            "unreachable_code": by_kind.get("unreachable_code", 0),
            "high_confidence_count": sum(1 for i in items if i.confidence >= 0.9),
        }
