#!/usr/bin/env python3
# File: scripts/comment_header_audit.py
# Purpose: Audit Python modules for header comment and docstring compliance (tiered).
# Context: Supports AGENTS.md standards with tier-based enforcement (Domain strict, others relaxed).

"""Command-line tool to validate comment and docstring standards across src/ modules.

Tier-based enforcement:
- Domain layer: STRICT (all docstring sections required)
- Application layer: MODERATE (Purpose only)
- Infrastructure layer: RELAXED (one-liner only)
- Presentation layer: MINIMAL (header only)
- Tests: EXEMPT (no docstrings required)
"""

from __future__ import annotations

import ast
import sys
from collections.abc import Iterable
from dataclasses import dataclass
from enum import Enum
from pathlib import Path


class DocstringPolicy(Enum):
    """Tier-specific docstring enforcement policy."""

    STRICT = "strict"       # Domain layer
    MODERATE = "moderate"   # Application layer
    RELAXED = "relaxed"     # Infrastructure layer
    MINIMAL = "minimal"     # Presentation layer
    EXEMPT = "exempt"       # Tests


@dataclass
class Finding:
    """Represents a single compliance issue discovered during auditing.

    Purpose:
        Record details of a docstring/comment compliance violation
        for reporting and remediation.

    Side Effects:
        None - value object
    """

    file: Path
    lineno: int
    message: str
    tier: DocstringPolicy | None = None

    def format(self) -> str:
        """Format the finding for human-readable output.

        Purpose:
            Convert finding to single-line format for logging/reporting.

        Returns:
            String formatted as "file:line: message"

        Side Effects:
            None
        """

        return f"{self.file}:{self.lineno}: {self.message}"


HEADER_PREFIX = "# File:"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SRC_ROOT = PROJECT_ROOT / "src"
TIERED_MODE = False  # Global flag for tiered enforcement


def get_policy_for_file(file_path: Path) -> DocstringPolicy:
    """Determine docstring policy tier from file path.

    Purpose:
        Map file location to enforcement tier based on layer.

    Args:
        file_path: Path to Python file

    Returns:
        DocstringPolicy enum value (STRICT, MODERATE, RELAXED, MINIMAL, EXEMPT)

    Side Effects:
        None
    """
    path_str = str(file_path).lower().replace("\\", "/")

    if "tests" in path_str:
        return DocstringPolicy.EXEMPT
    if "src/noveler/domain" in path_str:
        return DocstringPolicy.STRICT
    if "src/noveler/application" in path_str:
        return DocstringPolicy.MODERATE
    if "src/noveler/infrastructure" in path_str:
        return DocstringPolicy.RELAXED
    if "src/noveler/presentation" in path_str:
        return DocstringPolicy.MINIMAL
    # Default to STRICT for unknown paths
    return DocstringPolicy.STRICT


def main(argv: list[str] | None = None) -> int:
    """Entry point for the audit command.

    Purpose:
        Parse arguments and run audit on target Python files.

    Args:
        argv: Command-line arguments (defaults to sys.argv[1:])

    Returns:
        Exit code (0 if all compliant, 1 if violations found)

    Side Effects:
        - Reads files from disk
        - Prints audit results to stdout
    """
    global TIERED_MODE

    args = argv or sys.argv[1:]
    targets = [SRC_ROOT]

    # Parse --tiered flag
    if "--tiered" in args:
        TIERED_MODE = True
        args = [arg for arg in args if arg != "--tiered"]

    if args:
        targets = [Path(arg) for arg in args]

    findings: list[Finding] = []
    for path in iter_python_files(targets):
        findings.extend(audit_file(path))

    if not findings:
        print("✅ Comment audit: all files meet header and docstring standards.")
        return 0

    print("❌ Comment audit: issues detected. See details below:\n")
    for finding in findings:
        print(finding.format())

    print(f"\nTotal findings: {len(findings)}")
    return 1


def iter_python_files(paths: Iterable[Path]) -> Iterable[Path]:
    """Yield Python files under the given paths."""

    for path in paths:
        if path.is_file() and path.suffix == ".py":
            yield path
        elif path.is_dir():
            yield from path.rglob("*.py")


def audit_file(path: Path) -> list[Finding]:
    """Inspect a single file and return any compliance findings.

    Purpose:
        Parse Python AST and validate header comments and docstrings
        against tier-specific requirements.

    Args:
        path: Path to Python file

    Returns:
        List of Finding objects (empty if fully compliant)

    Side Effects:
        - Reads file from disk
        - Parses Python AST
    """

    findings: list[Finding] = []
    policy = get_policy_for_file(path) if TIERED_MODE else DocstringPolicy.STRICT
    text = path.read_text(encoding="utf-8")

    # Always check header comment (required for all tiers)
    header_finding = check_header_comment(path, text)
    if header_finding:
        header_finding.tier = policy
        findings.append(header_finding)

    # EXEMPT tier requires no further checks
    if policy == DocstringPolicy.EXEMPT:
        return findings

    try:
        tree = ast.parse(text, filename=str(path))
    except SyntaxError as exc:
        finding = Finding(
            file=path,
            lineno=exc.lineno or 1,
            message=f"Syntax error prevents audit: {exc}",
            tier=policy
        )
        findings.append(finding)
        return findings

    # Module-level docstring check (required for non-MINIMAL tiers)
    if policy != DocstringPolicy.MINIMAL:
        module_docstring = ast.get_docstring(tree)
        if not module_docstring:
            finding = Finding(
                file=path,
                lineno=1,
                message="Missing module docstring that documents Purpose.",
                tier=policy
            )
            findings.append(finding)
        else:
            findings.extend(
                validate_docstring_tiered(
                    path, 1, module_docstring, policy,
                    has_returns=False, has_args=False, has_raises=False
                )
            )

    # Class and function docstring checks
    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef):
            doc = ast.get_docstring(node)
            if not doc and policy not in (DocstringPolicy.MINIMAL, DocstringPolicy.RELAXED):
                finding = Finding(
                    file=path,
                    lineno=node.lineno,
                    message=f"Class '{node.name}' lacks docstring.",
                    tier=policy
                )
                findings.append(finding)
                continue
            if doc:
                findings.extend(
                    validate_docstring_tiered(
                        path, node.lineno, doc, policy,
                        has_returns=False, has_args=False, has_raises=False
                    )
                )

        elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
            doc = ast.get_docstring(node)
            if not doc and policy not in (DocstringPolicy.MINIMAL, DocstringPolicy.RELAXED):
                finding = Finding(
                    file=path,
                    lineno=node.lineno,
                    message=f"Function '{node.name}' lacks docstring.",
                    tier=policy
                )
                findings.append(finding)
                continue
            if doc:
                has_returns = function_has_return(node)
                has_raises = function_has_raise(node)
                arg_names = [arg.arg for arg in node.args.args]
                filtered_args = [name for name in arg_names if name not in {"self", "cls"}]
                findings.extend(
                    validate_docstring_tiered(
                        path,
                        node.lineno,
                        doc,
                        policy,
                        has_returns=has_returns,
                        has_args=bool(filtered_args or node.args.kwonlyargs or node.args.vararg or node.args.kwarg),
                        has_raises=has_raises,
                    )
                )

    return findings


def check_header_comment(path: Path, text: str) -> Finding | None:
    """Validate that the first meaningful line is the required header comment."""

    lines = text.splitlines()
    for lineno, line in enumerate(lines, start=1):
        stripped = line.strip()
        if not stripped:
            continue
        if stripped.startswith("#!"):
            continue
        if not stripped.startswith("#"):
            return Finding(file=path, lineno=lineno, message="Missing header comment (expected '# File: ...').")
        if not stripped.startswith(HEADER_PREFIX):
            return Finding(file=path, lineno=lineno, message="Header comment must start with '# File:'")
        return None
    return Finding(file=path, lineno=1, message="File is empty or missing header comment.")


def validate_docstring_tiered(
    path: Path,
    lineno: int,
    docstring: str,
    policy: DocstringPolicy,
    *,
    has_returns: bool,
    has_args: bool,
    has_raises: bool,
) -> list[Finding]:
    """Check docstring contains required sections based on tier.

    Purpose:
        Validate docstring content against tier-specific requirements.
        STRICT tier requires all sections; others require only Purpose.

    Args:
        path: Path to source file
        lineno: Line number of docstring
        docstring: Docstring content
        policy: DocstringPolicy tier
        has_returns: Function returns a value
        has_args: Function has parameters
        has_raises: Function raises exceptions

    Returns:
        List of Finding objects for violations

    Side Effects:
        None
    """

    findings: list[Finding] = []
    normalized = "\n" + docstring.lower()

    # All tiers except MINIMAL require Purpose section
    if policy != DocstringPolicy.MINIMAL:
        if "purpose:" not in normalized:
            finding = Finding(
                file=path,
                lineno=lineno,
                message="Docstring must include 'Purpose:' section.",
                tier=policy
            )
            findings.append(finding)

    # STRICT tier requires all sections
    if policy == DocstringPolicy.STRICT:
        if has_args and "args:" not in normalized:
            finding = Finding(
                file=path,
                lineno=lineno,
                message="Docstring must include 'Args:' section for parameters.",
                tier=policy
            )
            findings.append(finding)
        if has_returns and "returns:" not in normalized:
            finding = Finding(
                file=path,
                lineno=lineno,
                message="Docstring must include 'Returns:' section when values are returned.",
                tier=policy
            )
            findings.append(finding)
        if has_raises and "raises:" not in normalized:
            finding = Finding(
                file=path,
                lineno=lineno,
                message="Docstring must include 'Raises:' section when exceptions are raised.",
                tier=policy
            )
            findings.append(finding)
        if "side effects" not in normalized and "side-effects" not in normalized:
            finding = Finding(
                file=path,
                lineno=lineno,
                message="Docstring must describe side effects using 'Side Effects:' section.",
                tier=policy
            )
            findings.append(finding)

    # MODERATE tier only requires Purpose (already checked above)
    # RELAXED and MINIMAL tiers only require Purpose (or nothing for MINIMAL)

    return findings


def function_has_return(node: ast.AST) -> bool:
    """Detect whether a function returns a value."""

    for child in ast.walk(node):
        if isinstance(child, ast.Return) and child.value is not None:
            return True
    return False


def function_has_raise(node: ast.AST) -> bool:
    """Detect whether a function raises an exception."""

    for child in ast.walk(node):
        if isinstance(child, ast.Raise):
            return True
    return False


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