"""Built-in semantic validation rules for Python projects.

Provides pluggable rule implementations for syntax errors, missing
imports, broken references, duplicate relative imports, API misuse,
and missing symbols.  Each rule conforms to the ``SemanticValidationRule``
protocol.

Rules are designed to be lightweight, AST-based heuristics that
avoid executing user code.

Based on ``docs/specification.md`` and ADR-013 (Validation Abstraction).
"""

from __future__ import annotations

import ast
import builtins as _builtins_mod
import sys
from collections import Counter
from enum import StrEnum
from typing import ClassVar

from pydantic import BaseModel, ConfigDict, Field

# ---------------------------------------------------------------------------
# Shared severity enum + result model (canonical definitions)
# ---------------------------------------------------------------------------


class SemanticValidationSeverity(StrEnum):
    """Severity level for semantic validation findings."""

    INFO = "info"
    WARN = "warn"
    ERROR = "error"


class SemanticCheckResult(BaseModel):
    """Normalised result from a single semantic check.

    Attributes:
        passed: Whether the check passed (no issues found).
        message: Human-readable description of the result.
        data: Optional structured data with finding details.
        severity: Severity level of the finding.
    """

    passed: bool = Field(..., description="Whether the check passed")
    message: str = Field(default="", description="Human-readable result description")
    data: dict[str, object] | None = Field(
        default=None, description="Optional structured finding data"
    )
    severity: SemanticValidationSeverity = Field(
        default=SemanticValidationSeverity.INFO,
        description="Severity of the finding",
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )


# ---------------------------------------------------------------------------
# Built-in rules
# ---------------------------------------------------------------------------

_BUILTINS_SET: frozenset[str] = frozenset(dir(_builtins_mod))

# Use the authoritative stdlib module list (Python 3.10+) instead of a
# hand-maintained subset so that internal modules like ``_thread``,
# ``_abc``, etc. are recognised and do not trigger false positives.
_STDLIB_MODULE_NAMES: frozenset[str] = frozenset(sys.stdlib_module_names)


class SyntaxCheckRule:
    """Check Python source for syntax errors via ``ast.parse``."""

    @property
    def name(self) -> str:
        return "syntax_error"

    def check(self, source: str, filename: str) -> SemanticCheckResult:
        try:
            ast.parse(source, filename=filename)
        except SyntaxError as exc:
            lineno = exc.lineno or 0
            return SemanticCheckResult(
                passed=False,
                message=f"Syntax error at {filename}:{lineno}: {exc.msg}",
                data={"filename": filename, "line": lineno, "detail": exc.msg},
                severity=SemanticValidationSeverity.ERROR,
            )
        return SemanticCheckResult(
            passed=True,
            message=f"No syntax errors in {filename}",
            severity=SemanticValidationSeverity.INFO,
        )


class MissingImportRule:
    """Detect import statements that reference suspicious modules.

    Uses ``ast`` to find import names that look like private
    internal modules (leading underscore) that are not in the
    standard library.  Uses ``sys.stdlib_module_names`` for an
    authoritative list of stdlib top-level modules.
    """

    @property
    def name(self) -> str:
        return "missing_import"

    def check(self, source: str, filename: str) -> SemanticCheckResult:
        try:
            tree = ast.parse(source, filename=filename)
        except SyntaxError:
            return SemanticCheckResult(
                passed=True,
                message="Skipped import check (syntax error)",
                severity=SemanticValidationSeverity.INFO,
            )

        suspicious: list[dict[str, object]] = []
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    top = alias.name.split(".")[0]
                    if top.startswith("_") and top not in _STDLIB_MODULE_NAMES:
                        suspicious.append({"module": alias.name, "line": node.lineno})
            elif (
                isinstance(node, ast.ImportFrom)
                and node.module
                and node.module.startswith("_")
            ):
                top = node.module.split(".")[0]
                if top not in _STDLIB_MODULE_NAMES:
                    suspicious.append({"module": node.module, "line": node.lineno})

        if suspicious:
            return SemanticCheckResult(
                passed=False,
                message=(f"Found {len(suspicious)} suspicious import(s) in {filename}"),
                data={"filename": filename, "imports": suspicious},
                severity=SemanticValidationSeverity.WARN,
            )
        return SemanticCheckResult(
            passed=True,
            message=f"No missing imports detected in {filename}",
            severity=SemanticValidationSeverity.INFO,
        )


# ---------------------------------------------------------------------------
# Scope-aware name collection helpers
# ---------------------------------------------------------------------------


def _walk_and_collect(tree: ast.Module, *, deep: bool) -> set[str]:
    """Shared name-collection walker.

    When *deep* is ``False`` only direct children of *tree* are
    inspected (module-scope bindings).  When *deep* is ``True``
    the entire AST is walked to capture bindings at every scope
    level, including ``ast.Name`` nodes in ``Store`` context
    (e.g. comprehension targets).
    """
    names: set[str] = set()
    iterator = ast.walk(tree) if deep else ast.iter_child_nodes(tree)
    for node in iterator:
        _collect_binding_names(node, names)
        if deep and isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
            names.add(node.id)
    return names


def _collect_defined_names(tree: ast.Module) -> set[str]:
    """Collect names defined at module scope.

    Handles function/class definitions, imports, assignments
    (including annotated and augmented), for-loop targets,
    with-statement variables, and exception handler names.

    Uses :func:`_walk_and_collect` with ``deep=False`` to restrict
    collection to top-level bindings only.
    """
    return _walk_and_collect(tree, deep=False)


def _collect_binding_names(node: ast.AST, out: set[str]) -> None:
    """Add names bound by *node* to *out*.

    Recognised binding forms: ``FunctionDef``/``AsyncFunctionDef``
    (name **and** parameters), ``ClassDef``, ``Import``,
    ``ImportFrom``, ``Assign``, ``AnnAssign``, ``AugAssign``,
    ``For``/``AsyncFor`` targets, ``With``/``AsyncWith`` optional
    vars, and ``ExceptHandler`` names.
    """
    if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
        out.add(node.name)
        # Collect parameter names (positional, keyword-only, vararg, kwarg)
        for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
            out.add(arg.arg)
        if node.args.vararg:
            out.add(node.args.vararg.arg)
        if node.args.kwarg:
            out.add(node.args.kwarg.arg)
    elif isinstance(node, ast.ClassDef):
        out.add(node.name)
    elif isinstance(node, ast.Assign):
        for target in node.targets:
            _collect_target_names(target, out)
    elif isinstance(node, (ast.AnnAssign, ast.AugAssign)) and node.target:
        _collect_target_names(node.target, out)
    elif isinstance(node, ast.Import):
        for alias in node.names:
            n = alias.asname if alias.asname else alias.name.split(".")[0]
            out.add(n)
    elif isinstance(node, ast.ImportFrom):
        for alias in node.names:
            n = alias.asname if alias.asname else alias.name
            out.add(n)
    elif isinstance(node, ast.For | ast.AsyncFor):
        _collect_target_names(node.target, out)
    elif isinstance(node, ast.With | ast.AsyncWith):
        for item in node.items:
            if item.optional_vars:
                _collect_target_names(item.optional_vars, out)
    elif isinstance(node, ast.ExceptHandler) and node.name:
        out.add(node.name)


def _collect_target_names(target: ast.AST, out: set[str]) -> None:
    """Recursively extract names from an assignment target."""
    if isinstance(target, ast.Name):
        out.add(target.id)
    elif isinstance(target, ast.Tuple | ast.List):
        for elt in target.elts:
            _collect_target_names(elt, out)
    elif isinstance(target, ast.Starred):
        _collect_target_names(target.value, out)


def _collect_all_scope_names(tree: ast.Module) -> set[str]:
    """Collect every name defined anywhere in the module.

    Walks the entire AST to find all binding forms at every scope
    level.  Used by ``BrokenReferenceRule`` so that names defined
    inside functions, classes, loops, etc. are not falsely flagged
    as broken references.

    Uses :func:`_walk_and_collect` with ``deep=True`` to recurse
    through all nested scopes.
    """
    return _walk_and_collect(tree, deep=True)


_SPECIAL_DUNDER_NAMES: frozenset[str] = frozenset(
    {
        "__name__",
        "__file__",
        "__doc__",
        "__all__",
        "__annotations__",
        "__spec__",
        "__loader__",
        "__package__",
        "__builtins__",
        "__cached__",
        "__path__",
    }
)


class BrokenReferenceRule:
    """Detect references to names not defined in a reachable scope.

    Uses scope-aware checking: module-level references are validated
    against module-level definitions only, while references inside
    functions and classes are validated against all definitions
    (since they can access enclosing scopes).  This avoids false
    negatives where a name defined only inside a function is treated
    as visible at module scope.
    """

    @property
    def name(self) -> str:
        return "broken_reference"

    def check(self, source: str, filename: str) -> SemanticCheckResult:
        try:
            tree = ast.parse(source, filename=filename)
        except SyntaxError:
            return SemanticCheckResult(
                passed=True,
                message="Skipped reference check (syntax error)",
                severity=SemanticValidationSeverity.INFO,
            )

        # Module-level definitions: walk each top-level statement deeply
        # so that names bound inside compound statements (try/except,
        # if/else, for, while, with) at module scope are captured.
        # For FunctionDef/ClassDef at module level we only record the
        # name itself — internal bindings are scope-private.
        module_defined: set[str] = set()
        for stmt in tree.body:
            if isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
                module_defined.add(stmt.name)
            else:
                for node in ast.walk(stmt):
                    _collect_binding_names(node, module_defined)
                    if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
                        module_defined.add(node.id)
        module_defined.update(_BUILTINS_SET)

        # All definitions across every scope (for nested code)
        all_defined = _collect_all_scope_names(tree)
        all_defined.update(_BUILTINS_SET)

        # Partition Name(Load) nodes into module-level vs nested
        module_level_refs: set[str] = set()
        nested_refs: set[str] = set()

        for stmt in tree.body:
            if isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
                # References inside function/class bodies are nested
                for node in ast.walk(stmt):
                    if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
                        nested_refs.add(node.id)
            else:
                # Everything else is module-level code
                for node in ast.walk(stmt):
                    if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
                        module_level_refs.add(node.id)

        # Module-level refs: check against module-scope definitions only
        broken_module = module_level_refs - module_defined
        # Nested refs: check against all definitions (more permissive)
        broken_nested = nested_refs - all_defined

        broken = (broken_module | broken_nested) - _SPECIAL_DUNDER_NAMES

        if broken:
            limited = sorted(broken)[:10]
            return SemanticCheckResult(
                passed=False,
                message=(
                    f"Found {len(broken)} potentially broken reference(s) "
                    f"in {filename}: {', '.join(limited)}"
                ),
                data={"filename": filename, "broken": limited},
                severity=SemanticValidationSeverity.WARN,
            )
        return SemanticCheckResult(
            passed=True,
            message=f"No broken references in {filename}",
            severity=SemanticValidationSeverity.INFO,
        )


class DuplicateImportRule:
    """Detect duplicate relative imports that hint at dependency issues.

    Scans a single file for repeated relative import statements.
    Duplicate relative imports within one module are a code smell
    that *may* indicate copy-paste errors or structural problems.

    .. note::

       This rule does **not** perform cross-file cycle detection.
       It only flags duplicate relative imports within a single
       file.
    """

    @property
    def name(self) -> str:
        return "duplicate_import"

    def check(self, source: str, filename: str) -> SemanticCheckResult:
        try:
            tree = ast.parse(source, filename=filename)
        except SyntaxError:
            return SemanticCheckResult(
                passed=True,
                message="Skipped duplicate import check (syntax error)",
                severity=SemanticValidationSeverity.INFO,
            )

        relative_imports: list[str] = []
        for node in ast.walk(tree):
            if isinstance(node, ast.ImportFrom) and node.level and node.level > 0:
                base = node.module or ""
                names = ",".join(a.name for a in (node.names or []))
                relative_imports.append(f"{base}:{names}")

        counts = Counter(relative_imports)
        duplicates = [m for m, c in counts.items() if c > 1]
        if duplicates:
            return SemanticCheckResult(
                passed=False,
                message=(
                    f"Duplicate relative imports in {filename} "
                    f"(potential cycle): {', '.join(duplicates)}"
                ),
                data={"filename": filename, "duplicates": duplicates},
                severity=SemanticValidationSeverity.ERROR,
            )
        return SemanticCheckResult(
            passed=True,
            message=f"No duplicate relative imports in {filename}",
            severity=SemanticValidationSeverity.INFO,
        )


# Keep the old name as a public alias for backwards compatibility.
DependencyCycleRule = DuplicateImportRule


class APIMisuseRule:
    """Detect common API misuse patterns in Python code.

    Uses AST analysis to detect calls to dangerous functions such as
    ``eval()``, ``exec()``, ``os.system()``, ``os.popen()``,
    ``pickle.load()``, ``pickle.loads()``, ``marshal.loads()``,
    ``compile()``, and ``__import__()``.  AST-based detection avoids
    false positives from string literals and comments.
    """

    # Dangerous bare function names (resolved via ast.Name nodes).
    _DANGEROUS_NAMES: ClassVar[frozenset[str]] = frozenset(
        {"eval", "exec", "compile", "__import__"}
    )

    _DANGEROUS_NAME_DESCRIPTIONS: ClassVar[dict[str, str]] = {
        "eval": "Use of eval() is discouraged",
        "exec": "Use of exec() is discouraged",
        "compile": "Use of compile() can execute arbitrary code",
        "__import__": "Use importlib instead of __import__()",
    }

    # Dangerous qualified calls (module.func patterns).
    _DANGEROUS_ATTRS: ClassVar[dict[str, dict[str, str]]] = {
        "os": {
            "system": "Use subprocess instead of os.system()",
            "popen": "Use subprocess instead of os.popen()",
        },
        "subprocess": {
            "call": "subprocess.call can execute arbitrary commands",
            "run": "subprocess.run can execute arbitrary commands",
            "Popen": "subprocess.Popen can execute arbitrary commands",
        },
        "pickle": {
            "load": "pickle.load is unsafe with untrusted data",
            "loads": "pickle.loads is unsafe with untrusted data",
        },
        "marshal": {
            "loads": "marshal.loads is unsafe with untrusted data",
        },
    }

    @property
    def name(self) -> str:
        return "api_misuse"

    def check(self, source: str, filename: str) -> SemanticCheckResult:
        try:
            tree = ast.parse(source, filename=filename)
        except SyntaxError:
            return SemanticCheckResult(
                passed=True,
                message="Skipped API misuse check (syntax error)",
                severity=SemanticValidationSeverity.INFO,
            )

        findings: list[dict[str, object]] = []
        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            func = node.func
            # Bare name calls: eval(...), exec(...), compile(...), __import__(...)
            if isinstance(func, ast.Name) and func.id in self._DANGEROUS_NAMES:
                findings.append(
                    {
                        "line": node.lineno,
                        "call": func.id,
                        "description": self._DANGEROUS_NAME_DESCRIPTIONS[func.id],
                    }
                )
            # Attribute calls: os.system(...), pickle.loads(...)
            elif (
                isinstance(func, ast.Attribute)
                and isinstance(func.value, ast.Name)
                and func.value.id in self._DANGEROUS_ATTRS
            ):
                attr_map = self._DANGEROUS_ATTRS[func.value.id]
                if func.attr in attr_map:
                    findings.append(
                        {
                            "line": node.lineno,
                            "call": f"{func.value.id}.{func.attr}",
                            "description": attr_map[func.attr],
                        }
                    )

        if findings:
            return SemanticCheckResult(
                passed=False,
                message=(f"Found {len(findings)} API misuse pattern(s) in {filename}"),
                data={"filename": filename, "findings": findings},
                severity=SemanticValidationSeverity.WARN,
            )
        return SemanticCheckResult(
            passed=True,
            message=f"No API misuse patterns in {filename}",
            severity=SemanticValidationSeverity.INFO,
        )


# ---------------------------------------------------------------------------
# MissingSymbolRule helpers
# ---------------------------------------------------------------------------


def _collect_function_local_names(
    node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> set[str]:
    """Collect all names locally bound inside a function body.

    Includes parameters, assignments, for-loop targets,
    with-statement variables, exception handler names, nested
    function/class definitions, and comprehension variables.
    """
    local_names: set[str] = set()

    # Parameters
    for arg in node.args.args:
        local_names.add(arg.arg)
    for arg in node.args.posonlyargs:
        local_names.add(arg.arg)
    for arg in node.args.kwonlyargs:
        local_names.add(arg.arg)
    if node.args.vararg:
        local_names.add(node.args.vararg.arg)
    if node.args.kwarg:
        local_names.add(node.args.kwarg.arg)

    # Walk the body for all binding forms
    for child in ast.walk(node):
        if child is node:
            continue
        # Nested function/class definitions bind their name
        if isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
            local_names.add(child.name)
        # Store context names (assignments, comprehension targets)
        elif isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store):
            local_names.add(child.id)
        # For-loop targets
        elif isinstance(child, ast.For | ast.AsyncFor):
            _collect_target_names(child.target, local_names)
        # With-statement variables
        elif isinstance(child, ast.With | ast.AsyncWith):
            for item in child.items:
                if item.optional_vars:
                    _collect_target_names(item.optional_vars, local_names)
        # Exception handler names
        elif isinstance(child, ast.ExceptHandler) and child.name:
            local_names.add(child.name)
        # Import inside function
        elif isinstance(child, ast.Import):
            for alias in child.names:
                n = alias.asname if alias.asname else alias.name.split(".")[0]
                local_names.add(n)
        elif isinstance(child, ast.ImportFrom):
            for alias in child.names:
                n = alias.asname if alias.asname else alias.name
                local_names.add(n)

    return local_names


def _iter_functions(
    tree: ast.AST,
) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
    """Yield all function/method definitions at any nesting depth."""
    results: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
    for node in ast.walk(tree):
        if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
            results.append(node)
    return results


def _walk_scope(node: ast.AST) -> list[ast.AST]:
    """Walk *node* without descending into nested function/class bodies.

    Returns all AST nodes belonging to the current scope only.
    Nested ``FunctionDef`` / ``AsyncFunctionDef`` / ``ClassDef``
    nodes are yielded (so their *name* is visible) but their
    child nodes are **not** traversed.  This prevents a parent
    function's Load-reference check from seeing names that only
    exist inside an inner function's scope.
    """
    result: list[ast.AST] = []
    stack = [node]
    while stack:
        current = stack.pop()
        result.append(current)
        for child in ast.iter_child_nodes(current):
            if child is not node and isinstance(
                child,
                ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
            ):
                # Yield the node itself (its name is visible) but
                # do NOT descend into its body.
                result.append(child)
            else:
                stack.append(child)
    return result


def _build_enclosing_scope_names(
    tree: ast.Module,
) -> dict[int, set[str]]:
    """Map each function's ``id()`` to the union of all enclosing scope names.

    Walks the AST recursively, maintaining a stack of name-sets from
    enclosing functions.  For each ``FunctionDef`` /
    ``AsyncFunctionDef`` encountered, the merged enclosing names are
    recorded so that :class:`MissingSymbolRule` can recognise closure
    variables from outer scopes.
    """
    result: dict[int, set[str]] = {}

    def _visit(
        node: ast.AST,
        scope_stack: list[set[str]],
    ) -> None:
        if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
            # Record enclosing scope names for this function
            enclosing = set[str]()
            for scope in scope_stack:
                enclosing.update(scope)
            result[id(node)] = enclosing

            # Collect this function's local names for its children
            local = _collect_function_local_names(node)
            child_stack = [*scope_stack, local]
            for child in ast.iter_child_nodes(node):
                _visit(child, child_stack)
        else:
            for child in ast.iter_child_nodes(node):
                _visit(child, scope_stack)

    _visit(tree, [])
    return result


class MissingSymbolRule:
    """Detect undefined name usage in function and method bodies.

    Inspects all functions and methods at any nesting depth
    (including class methods and nested functions) for names that
    are used but not defined locally, in enclosing function scopes,
    at module scope, or in builtins.  Closure variables from
    enclosing functions are correctly recognised.
    """

    @property
    def name(self) -> str:
        return "missing_symbol"

    def check(self, source: str, filename: str) -> SemanticCheckResult:
        try:
            tree = ast.parse(source, filename=filename)
        except SyntaxError:
            return SemanticCheckResult(
                passed=True,
                message="Skipped symbol check (syntax error)",
                severity=SemanticValidationSeverity.INFO,
            )

        module_names = _collect_defined_names(tree)
        functions = _iter_functions(tree)
        enclosing_map = _build_enclosing_scope_names(tree)

        missing: list[dict[str, object]] = []
        for func_node in functions:
            local_names = _collect_function_local_names(func_node)
            enclosing_names = enclosing_map.get(id(func_node), set())

            # Use _walk_scope to avoid descending into nested
            # function/class bodies — each is checked independently.
            for child in _walk_scope(func_node):
                if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
                    nm = child.id
                    if (
                        nm not in local_names
                        and nm not in enclosing_names
                        and nm not in module_names
                        and nm not in _BUILTINS_SET
                    ):
                        missing.append(
                            {
                                "function": func_node.name,
                                "symbol": nm,
                                "line": child.lineno,
                            }
                        )

        if missing:
            limited = missing[:10]
            return SemanticCheckResult(
                passed=False,
                message=f"Found {len(missing)} missing symbol(s) in {filename}",
                data={"filename": filename, "missing": limited},
                severity=SemanticValidationSeverity.WARN,
            )
        return SemanticCheckResult(
            passed=True,
            message=f"No missing symbols in {filename}",
            severity=SemanticValidationSeverity.INFO,
        )
