"""Test coverage gap analyzer.

Identifies public functions and classes that lack corresponding test coverage
by cross-referencing source modules with test files.
"""

from __future__ import annotations

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


@dataclass
class UncoveredItem:
    """A public function or class without test coverage."""

    file_path: str
    name: str
    line_number: int
    kind: str  # "function", "class", "method"
    complexity: int = 1

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


class _PublicAPICollector(ast.NodeVisitor):
    """Collect public functions, classes, and methods from source code."""

    def __init__(self) -> None:
        self.items: list[tuple[str, int, str]] = []  # (name, lineno, kind)
        self._class_stack: list[str] = []

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        if not node.name.startswith("_"):
            self.items.append((node.name, node.lineno, "class"))
        self._class_stack.append(node.name)
        self.generic_visit(node)
        self._class_stack.pop()

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._process_func(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._process_func(node)

    def _process_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
        if node.name.startswith("_"):
            return
        if self._class_stack:
            full_name = f"{self._class_stack[-1]}.{node.name}"
            self.items.append((full_name, node.lineno, "method"))
        else:
            self.items.append((node.name, node.lineno, "function"))
        self.generic_visit(node)


class _TestReferenceCollector(ast.NodeVisitor):
    """Collect all names referenced in test files."""

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

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

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

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


class TestCoverageAnalyzer:
    """Analyze test coverage gaps by matching source to test files."""

    def analyze_directory(
        self,
        directory: str | Path,
        source_dirs: list[str] | None = None,
        test_dirs: list[str] | None = None,
        exclude_patterns: list[str] | None = None,
    ) -> list[UncoveredItem]:
        """Find public APIs without test coverage.

        Args:
            directory: Root directory of the project.
            source_dirs: Subdirectories containing source code (default: ["src"]).
            test_dirs: Subdirectories containing tests (default: ["tests", "test"]).
            exclude_patterns: Directory names to exclude.

        Returns:
            List of public items without test coverage.
        """
        directory = Path(directory)
        source_dirs = source_dirs or ["src", "lib", "app"]
        test_dirs = test_dirs or ["tests", "test"]
        exclude_patterns = exclude_patterns or [
            "venv", "node_modules", ".git", "__pycache__", ".tox",
            "build", "dist",
        ]

        # Collect all test references
        test_refs = self._collect_test_references(directory, test_dirs, exclude_patterns)

        # Collect all source definitions
        source_items = self._collect_source_definitions(
            directory, source_dirs, exclude_patterns
        )

        # Find uncovered items
        uncovered: list[UncoveredItem] = []
        for file_path, items in source_items.items():
            for name, lineno, kind in items:
                # Check both full name and simple name
                simple_name = name.split(".")[-1]
                # Look for test references: the name itself, test_name, TestName
                test_variants = {
                    simple_name,
                    f"test_{simple_name}",
                    f"Test{simple_name}",
                    f"test_{simple_name.lower()}",
                }
                if not test_variants & test_refs:
                    uncovered.append(
                        UncoveredItem(
                            file_path=file_path,
                            name=name,
                            line_number=lineno,
                            kind=kind,
                        )
                    )

        return uncovered

    def _collect_test_references(
        self,
        directory: Path,
        test_dirs: list[str],
        exclude_patterns: list[str],
    ) -> set[str]:
        """Collect all referenced names from test files."""
        refs: set[str] = set()

        # Find test files in test directories
        test_files: list[Path] = []
        for td in test_dirs:
            test_path = directory / td
            if test_path.exists():
                test_files.extend(test_path.rglob("*.py"))

        # Also find test_*.py and *_test.py in the root
        for py_file in directory.rglob("test_*.py"):
            parts = py_file.relative_to(directory).parts
            if not any(excl in parts for excl in exclude_patterns):
                test_files.append(py_file)
        for py_file in directory.rglob("*_test.py"):
            parts = py_file.relative_to(directory).parts
            if not any(excl in parts for excl in exclude_patterns):
                test_files.append(py_file)

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

            visitor = _TestReferenceCollector()
            visitor.visit(tree)
            refs.update(visitor.referenced)

            # Also extract names from test function names (test_my_function -> my_function)
            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    if node.name.startswith("test_"):
                        # test_calculate_score -> calculate_score
                        tested_name = node.name[5:]
                        refs.add(tested_name)

        return refs

    def _collect_source_definitions(
        self,
        directory: Path,
        source_dirs: list[str],
        exclude_patterns: list[str],
    ) -> dict[str, list[tuple[str, int, str]]]:
        """Collect public API definitions from source files."""
        items: dict[str, list[tuple[str, int, str]]] = {}

        # Look in source directories
        source_files: list[Path] = []
        for sd in source_dirs:
            src_path = directory / sd
            if src_path.exists():
                source_files.extend(src_path.rglob("*.py"))

        # Also check root-level Python files (non-test)
        for py_file in directory.glob("*.py"):
            if not py_file.name.startswith("test_") and not py_file.name.endswith("_test.py"):
                source_files.append(py_file)

        for sf in set(source_files):
            parts = sf.relative_to(directory).parts
            if any(excl in parts for excl in exclude_patterns):
                continue
            # Skip test files
            if sf.name.startswith("test_") or sf.name.endswith("_test.py"):
                continue

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

            collector = _PublicAPICollector()
            collector.visit(tree)
            if collector.items:
                items[str(sf)] = collector.items

        return items

    def summary(self, uncovered: list[UncoveredItem]) -> dict[str, Any]:
        """Generate test coverage gap summary."""
        by_kind: dict[str, int] = defaultdict(int)
        files: set[str] = set()
        for item in uncovered:
            by_kind[item.kind] += 1
            files.add(item.file_path)

        return {
            "total_uncovered_items": len(uncovered),
            "uncovered_functions": by_kind.get("function", 0),
            "uncovered_classes": by_kind.get("class", 0),
            "uncovered_methods": by_kind.get("method", 0),
            "files_with_gaps": len(files),
        }
