#!/usr/bin/env python3
"""Check for missing docstrings in Python source files.

Purpose:
    Verify that all public functions, classes, and modules have docstrings.
    Helps enforce Documentation Standardization Guide compliance.

Context:
    Part of CI pipeline. Can be run locally via `python scripts/ci/check_docstrings.py`.
    Integrates with Makefile (make check-docs) and GitHub Actions (pr-check.yml).

Side Effects:
    Outputs violations to stdout. Returns non-zero exit code on failures.
"""

from __future__ import annotations

import ast
import sys
from dataclasses import dataclass
from pathlib import Path


@dataclass
class DocstringViolation:
    """Docstring violation record.

    Purpose:
        Represent a single missing or incomplete docstring.

    Attributes:
        file_path: Path to the file containing the violation.
        line_number: Line number where the violation occurs.
        symbol_name: Name of the function/class without docstring.
        symbol_type: Type of symbol (function, class, async function).
    """

    file_path: Path
    line_number: int
    symbol_name: str
    symbol_type: str


class DocstringChecker(ast.NodeVisitor):
    """Check for missing docstrings in AST nodes.

    Purpose:
        Traverse Python AST and detect functions/classes without docstrings.

    Attributes:
        violations: List of DocstringViolation instances found.
        file_path: Path to the file being checked.
        skip_private: Whether to skip private (underscore-prefixed) symbols.
    """

    def __init__(self, file_path: Path, skip_private: bool = True) -> None:
        """Initialize docstring checker.

        Purpose:
            Set up the checker for a specific file.

        Args:
            file_path: Path to the Python file to check.
            skip_private: Skip private functions/classes (default: True).

        Side Effects:
            None.
        """
        self.violations: list[DocstringViolation] = []
        self.file_path = file_path
        self.skip_private = skip_private

    def _has_docstring(self, node: ast.stmt) -> bool:
        """Check if a node has a docstring.

        Purpose:
            Determine if function/class has a docstring (first statement is string).

        Args:
            node: AST node to check.

        Returns:
            True if docstring exists, False otherwise.

        Side Effects:
            None.
        """
        return (
            isinstance(node.body, list)
            and len(node.body) > 0
            and isinstance(node.body[0], ast.Expr)
            and isinstance(node.body[0].value, ast.Constant)
            and isinstance(node.body[0].value.value, str)
        )

    def _is_private(self, name: str) -> bool:
        """Check if symbol is private (starts with underscore).

        Purpose:
            Filter out private methods/attributes from checks.

        Args:
            name: Symbol name to check.

        Returns:
            True if symbol is private, False otherwise.

        Side Effects:
            None.
        """
        return self.skip_private and name.startswith("_")

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        """Visit function definition.

        Purpose:
            Check if function has docstring.

        Args:
            node: FunctionDef AST node.

        Side Effects:
            Appends to self.violations if docstring missing.
        """
        if not self._is_private(node.name):
            if not self._has_docstring(node):
                self.violations.append(
                    DocstringViolation(
                        file_path=self.file_path,
                        line_number=node.lineno,
                        symbol_name=node.name,
                        symbol_type="function",
                    )
                )
        self.generic_visit(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        """Visit async function definition.

        Purpose:
            Check if async function has docstring.

        Args:
            node: AsyncFunctionDef AST node.

        Side Effects:
            Appends to self.violations if docstring missing.
        """
        if not self._is_private(node.name):
            if not self._has_docstring(node):
                self.violations.append(
                    DocstringViolation(
                        file_path=self.file_path,
                        line_number=node.lineno,
                        symbol_name=node.name,
                        symbol_type="async function",
                    )
                )
        self.generic_visit(node)

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        """Visit class definition.

        Purpose:
            Check if class has docstring.

        Args:
            node: ClassDef AST node.

        Side Effects:
            Appends to self.violations if docstring missing.
        """
        if not self._is_private(node.name):
            if not self._has_docstring(node):
                self.violations.append(
                    DocstringViolation(
                        file_path=self.file_path,
                        line_number=node.lineno,
                        symbol_name=node.name,
                        symbol_type="class",
                    )
                )
        self.generic_visit(node)


def check_file(file_path: Path, skip_private: bool = True) -> list[DocstringViolation]:
    """Check a single Python file for missing docstrings.

    Purpose:
        Parse a Python file and identify functions/classes without docstrings.

    Args:
        file_path: Path to Python file to check.
        skip_private: Whether to skip private symbols (default: True).

    Returns:
        List of DocstringViolation instances found.

    Raises:
        SyntaxError: When file has syntax errors.

    Side Effects:
        None.
    """
    try:
        with open(file_path, encoding="utf-8") as f:
            tree = ast.parse(f.read(), filename=str(file_path))
    except SyntaxError as e:
        print(f"⚠️  {file_path}: Syntax error - {e}", file=sys.stderr)
        return []
    except UnicodeDecodeError as e:
        print(f"⚠️  {file_path}: Encoding error - {e}", file=sys.stderr)
        return []

    checker = DocstringChecker(file_path, skip_private=skip_private)
    checker.visit(tree)
    return checker.violations


def check_directory(
    directory: Path,
    skip_private: bool = True,
    exclude_patterns: list[str] | None = None,
) -> list[DocstringViolation]:
    """Check all Python files in a directory.

    Purpose:
        Recursively find and check all Python files.

    Args:
        directory: Root directory to check.
        skip_private: Whether to skip private symbols (default: True).
        exclude_patterns: Glob patterns to exclude (default: None).

    Returns:
        List of all DocstringViolation instances found.

    Side Effects:
        None.
    """
    if exclude_patterns is None:
        exclude_patterns = [
            "**/__pycache__/**",
            "**/.git/**",
            "**/.*/**",
            "**/.venv/**",
            "**/venv/**",
        ]

    violations: list[DocstringViolation] = []

    for py_file in directory.rglob("*.py"):
        # Skip excluded patterns
        if any(py_file.match(pattern) for pattern in exclude_patterns):
            continue

        violations.extend(check_file(py_file, skip_private=skip_private))

    return violations


def format_violation(violation: DocstringViolation) -> str:
    """Format a violation for display.

    Purpose:
        Create human-readable violation message.

    Args:
        violation: DocstringViolation instance.

    Returns:
        Formatted violation string.

    Side Effects:
        None.
    """
    return (
        f"{violation.file_path}:{violation.line_number}: "
        f"Missing docstring for {violation.symbol_type} '{violation.symbol_name}'"
    )


def main() -> int:
    """Main entry point.

    Purpose:
        Check source code for missing docstrings and report violations.

    Returns:
        0 if no violations, 1 if violations found.

    Side Effects:
        Prints violations to stdout/stderr.
    """
    # Check src/ and scripts/ directories
    check_dirs = [Path("src"), Path("scripts")]

    all_violations: list[DocstringViolation] = []
    for check_dir in check_dirs:
        if check_dir.exists():
            print(f"🔍 Checking {check_dir}/...")
            violations = check_directory(
                check_dir,
                skip_private=True,
                exclude_patterns=[
                    "**/__pycache__/**",
                    "**/.git/**",
                    "**/.*/**",
                    "**/.venv/**",
                    "**/venv/**",
                    "**/migrations/**",
                    "**/test_*.py",
                    "**/conftest.py",
                ],
            )
            all_violations.extend(violations)

    if all_violations:
        print(f"\n❌ Found {len(all_violations)} missing docstrings:\n")
        for violation in all_violations:
            print(f"  {format_violation(violation)}")
        print("\n⚠️  Documentation Standard: See docs/guides/documentation_standardization_guide.md")
        return 1

    print("✅ All public functions and classes have docstrings!")
    return 0


if __name__ == "__main__":
    sys.exit(main())
