"""Documentation gap finder.

Identifies public functions, classes, and methods that lack docstrings.
"""

from __future__ import annotations

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


@dataclass
class MissingDocItem:
    """A public API element missing documentation."""

    file_path: str
    name: str
    line_number: int
    kind: str  # "function", "class", "method", "module"

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


class _DocstringChecker(ast.NodeVisitor):
    """Check for missing docstrings on public APIs."""

    def __init__(self, file_path: str) -> None:
        self.file_path = file_path
        self.missing: list[MissingDocItem] = []
        self._class_stack: list[str] = []

    def visit_Module(self, node: ast.Module) -> None:
        docstring = ast.get_docstring(node)
        if not docstring:
            self.missing.append(
                MissingDocItem(
                    file_path=self.file_path,
                    name="<module>",
                    line_number=1,
                    kind="module",
                )
            )
        self.generic_visit(node)

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        if not node.name.startswith("_"):
            docstring = ast.get_docstring(node)
            if not docstring:
                self.missing.append(
                    MissingDocItem(
                        file_path=self.file_path,
                        name=node.name,
                        line_number=node.lineno,
                        kind="class",
                    )
                )
        self._class_stack.append(node.name)
        self.generic_visit(node)
        self._class_stack.pop()

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

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

    def _check_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
        # Skip private/dunder
        if node.name.startswith("_"):
            self.generic_visit(node)
            return

        docstring = ast.get_docstring(node)
        if not docstring:
            if self._class_stack:
                full_name = f"{self._class_stack[-1]}.{node.name}"
                kind = "method"
            else:
                full_name = node.name
                kind = "function"
            self.missing.append(
                MissingDocItem(
                    file_path=self.file_path,
                    name=full_name,
                    line_number=node.lineno,
                    kind=kind,
                )
            )
        self.generic_visit(node)


class DocumentationAnalyzer:
    """Find public APIs missing docstrings."""

    SUPPORTED_EXTENSIONS = {".py"}

    def analyze_file(self, file_path: str | Path) -> list[MissingDocItem]:
        """Analyze a single file for missing documentation."""
        file_path = Path(file_path)
        if file_path.suffix not in self.SUPPORTED_EXTENSIONS:
            return []

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

        checker = _DocstringChecker(str(file_path))
        checker.visit(tree)
        return checker.missing

    def analyze_directory(
        self,
        directory: str | Path,
        exclude_patterns: list[str] | None = None,
    ) -> list[MissingDocItem]:
        """Find all missing documentation in a directory."""
        directory = Path(directory)
        exclude_patterns = exclude_patterns or [
            "venv", "node_modules", ".git", "__pycache__", ".tox",
            "build", "dist", "migrations",
        ]

        results: list[MissingDocItem] = []
        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
            results.extend(self.analyze_file(py_file))

        return results

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

        return {
            "total_missing_docs": len(items),
            "missing_module_docs": by_kind.get("module", 0),
            "missing_class_docs": by_kind.get("class", 0),
            "missing_function_docs": by_kind.get("function", 0),
            "missing_method_docs": by_kind.get("method", 0),
            "files_with_gaps": len(files),
        }
