# File: src/noveler/tools/ddd_validator.py
# Purpose: Provide lightweight domain-driven design validation utilities for
#          enforcing layered architecture and rich domain model practices.
# Context: Used by B20/B30 quality checks and CLI helpers to analyse source
#          files under the noveler project structure.

from __future__ import annotations

import ast
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


@dataclass(frozen=True)
class LayerViolation:
    """Represents a dependency violation between DDD layers."""

    file_path: Path
    import_target: str
    message: str
    line_number: int | None = None


@dataclass(frozen=True)
class RepositoryPatternViolation:
    """Captures improper repository pattern placement."""

    file_path: Path
    class_name: str
    message: str
    line_number: int | None = None


@dataclass(frozen=True)
class AnemicModelWarning:
    """Warns about domain entities lacking business behaviour."""

    entity_name: str
    message: str
    suggestions: tuple[str, ...] = field(default_factory=tuple)


@dataclass
class DDDValidationResult:
    """Aggregated output for all validator checks."""

    layer_violations: list[LayerViolation] = field(default_factory=list)
    repository_violations: list[RepositoryPatternViolation] = field(default_factory=list)
    anemic_warnings: list[AnemicModelWarning] = field(default_factory=list)
    custom_rule_violations: dict[str, list[str]] = field(default_factory=dict)
    summary: dict[str, Any] = field(default_factory=dict)
    total_entities: int = 0
    total_files: int = 0
    compliance_score: float = 100.0

    @property
    def model_warnings(self) -> list[AnemicModelWarning]:
        """Backward compatible alias."""

        return self.anemic_warnings

    @property
    def has_failures(self) -> bool:
        """Return True when blocking violations exist."""

        return bool(self.layer_violations or self.repository_violations)

    @property
    def has_warnings(self) -> bool:
        """Return True when non-blocking warnings exist."""

        return bool(self.anemic_warnings or any(self.custom_rule_violations.values()))

    def calculate_compliance_score(self) -> float:
        """Compute a simple compliance score (0-100)."""

        penalty = (
            len(self.layer_violations) * 20
            + len(self.repository_violations) * 15
            + len(self.anemic_warnings) * 10
            + sum(len(messages) for messages in self.custom_rule_violations.values()) * 5
        )
        base_score = max(0.0, 100.0 - penalty)

        scale_factor = 1.0
        if self.total_files > 0:
            scale_factor *= min(1.0, 50 / max(1, self.total_files))
        if self.total_entities > 0:
            scale_factor *= min(1.0, 20 / max(1, self.total_entities))

        adjusted_score = max(0.0, min(100.0, base_score * max(scale_factor, 0.5)))
        self.compliance_score = round(adjusted_score, 2)
        return self.compliance_score


class DDDValidator:
    """Static analysis helper enforcing simple DDD rules within noveler."""

    _DISALLOWED_IMPORT_PREFIXES = {
        "domain": ("noveler.infrastructure.", "infrastructure."),
        "application": ("noveler.infrastructure.",),
    }

    def __init__(self, project_root: Path | None = None) -> None:
        self.project_root = Path(project_root) if project_root else None
        self._custom_rules: dict[str, Callable[[Path, str], Iterable[str]]] = {}

    # --------------------------------------------------------------------- #
    # Public API
    # --------------------------------------------------------------------- #

    def check_layer_dependencies(self, file_path: Path, source: str) -> list[LayerViolation]:
        """Detect forbidden inter-layer imports (e.g. domain -> infrastructure)."""

        layer = self._determine_layer(file_path)
        if layer not in ("domain", "application"):
            return []

        violations: list[LayerViolation] = []
        try:
            tree = ast.parse(source or "", filename=str(file_path))
        except SyntaxError as exc:  # pragma: no cover - defensive
            message = f"Failed to analyse imports due to syntax error: {exc}"
            violations.append(
                LayerViolation(file_path=file_path, import_target="<syntax-error>", message=message, line_number=exc.lineno)
            )
            return violations

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                modules = [alias.name for alias in node.names]
            elif isinstance(node, ast.ImportFrom):
                base = node.module or ""
                modules = [f"{base}.{alias.name}".strip(".") for alias in node.names]
            else:
                continue

            for module in modules:
                if self._is_disallowed_import(layer, module):
                    message = f"{layer} layer must not depend on infrastructure components (detected '{module}')."
                    violations.append(
                        LayerViolation(
                            file_path=file_path,
                            import_target=module,
                            message=message,
                            line_number=getattr(node, "lineno", None),
                        )
                    )

        return violations

    def check_rich_domain_models(self, source: str, class_name: str) -> list[AnemicModelWarning]:
        """Identify domain entities that expose only data without behaviour."""

        try:
            tree = ast.parse(source or "")
        except SyntaxError:
            return [
                AnemicModelWarning(
                    entity_name=class_name,
                    message="Unable to analyse domain model due to syntax error.",
                    suggestions=("Fix syntax errors before running the DDD validator.",),
                )
            ]

        target_class = next((node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == class_name), None)
        if target_class is None:
            return []

        meaningful_methods = [
            fn
            for fn in target_class.body
            if isinstance(fn, ast.FunctionDef) and fn.name != "__init__" and not self._is_trivial_method(fn)
        ]

        if meaningful_methods:
            return []

        suggestions = (
            "Introduce behaviour that encapsulates domain invariants.",
            "Move business rules from service layer into the entity.",
        )
        message = f"Detected an anemic domain model: '{class_name}' exposes no meaningful business behaviour."
        return [AnemicModelWarning(entity_name=class_name, message=message, suggestions=suggestions)]

    def check_repository_pattern(self, file_path: Path, source: str) -> list[RepositoryPatternViolation]:
        """Ensure repository interfaces live in the domain layer (not infrastructure)."""

        layer = self._determine_layer(file_path)
        if layer != "infrastructure":
            return []

        try:
            tree = ast.parse(source or "", filename=str(file_path))
        except SyntaxError:
            return [
                RepositoryPatternViolation(
                    file_path=file_path,
                    class_name="<syntax-error>",
                    message="Unable to analyse repository definitions due to syntax error.",
                )
            ]

        violations: list[RepositoryPatternViolation] = []
        for node in tree.body:
            if not isinstance(node, ast.ClassDef):
                continue
            if not node.name.endswith("Repository"):
                continue

            if self._is_repository_interface(node):
                message = (
                    "Repository interfaces must reside in the domain layer. "
                    "Move the abstract definition to 'noveler/domain/'."
                )
                violations.append(
                    RepositoryPatternViolation(
                        file_path=file_path,
                        class_name=node.name,
                        message=message,
                        line_number=getattr(node, "lineno", None),
                    )
                )

        return violations

    def run_full_validation(self, file_path: Path, source: str, class_name: str | None = None) -> DDDValidationResult:
        """Execute every check and aggregate the result."""

        result = DDDValidationResult()
        result.layer_violations.extend(self.check_layer_dependencies(file_path, source))
        result.repository_violations.extend(self.check_repository_pattern(file_path, source))
        if class_name:
            result.anemic_warnings.extend(self.check_rich_domain_models(source, class_name))
        return result

    def add_custom_rule(
        self,
        rule_name: str,
        rule: Callable[[Path, str], Iterable[str]],
    ) -> None:
        """Register an additional validation rule executed per file."""

        self._custom_rules[rule_name] = rule

    def apply_custom_rules(self, file_path: Path, source: str) -> list[str]:
        """Execute custom rules against a file and return all messages."""

        messages: list[str] = []
        for name, rule in self._custom_rules.items():
            try:
                findings = list(rule(file_path, source))
            except Exception as exc:  # pragma: no cover - defensive
                findings = [f"[{name}] rule failed: {exc}"]
            if findings:
                prefixed = [f"[{name}] {message}" for message in findings]
                messages.extend(prefixed)
        return messages

    def check_domain_service_logic(self, source: str, class_name: str) -> bool:
        """Heuristic check ensuring a service class contains business logic."""

        try:
            tree = ast.parse(source or "")
        except SyntaxError:
            return False

        for node in tree.body:
            if isinstance(node, ast.ClassDef) and node.name == class_name:
                for stmt in node.body:
                    if isinstance(stmt, ast.FunctionDef) and stmt.name != "__init__":
                        if not self._is_trivial_method(stmt):
                            return True
        return False

    def validate_project(self, project_root: Path) -> DDDValidationResult:
        """Traverse a project tree and aggregate all validation findings."""

        project_root = Path(project_root)
        result = DDDValidationResult()
        result.summary = {
            "layer_violations": 0,
            "repository_violations": 0,
            "anemic_models": 0,
            "custom_rule_violations": 0,
        }

        for file_path in project_root.rglob("*.py"):
            try:
                source = file_path.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError):
                continue

            result.total_files += 1

            layer_violations = self.check_layer_dependencies(file_path, source)
            result.layer_violations.extend(layer_violations)
            result.summary["layer_violations"] += len(layer_violations)

            repo_violations = self.check_repository_pattern(file_path, source)
            result.repository_violations.extend(repo_violations)
            result.summary["repository_violations"] += len(repo_violations)

            custom_messages = self.apply_custom_rules(file_path, source)
            if custom_messages:
                try:
                    key = str(file_path.relative_to(project_root))
                except ValueError:
                    key = str(file_path)
                result.custom_rule_violations[key] = custom_messages
                result.summary["custom_rule_violations"] += len(custom_messages)

            if "domain" in {part.lower() for part in file_path.parts}:
                class_names = self._collect_class_names(source)
                result.total_entities += len(class_names)
                for class_name in class_names:
                    warnings = self.check_rich_domain_models(source, class_name)
                    if warnings:
                        result.anemic_warnings.extend(warnings)
                        result.summary["anemic_models"] += len(warnings)

        result.calculate_compliance_score()
        result.summary["compliance_score"] = result.compliance_score
        return result

    def generate_report(self, validation_result: DDDValidationResult) -> str:
        """Return a multi-line textual report summarising validation output."""

        score = validation_result.calculate_compliance_score()
        lines = [
            "DDD Validation Report",
            "=====================",
            f"Layer Violations: {len(validation_result.layer_violations)}",
            f"Repository Violations: {len(validation_result.repository_violations)}",
            f"Anemic Models: {len(validation_result.anemic_warnings)}",
            f"Custom Rule Findings: {sum(len(v) for v in validation_result.custom_rule_violations.values())}",
            f"Compliance Score: {score}",
        ]
        return "\n".join(lines)

    # ------------------------------------------------------------------ #
    # Internal helpers
    # ------------------------------------------------------------------ #

    def _determine_layer(self, file_path: Path) -> str:
        """Infer the architectural layer from the file path."""

        path = file_path
        if not path.is_absolute() and self.project_root:
            path = self.project_root / path

        for part in reversed(path.parts):
            lowered = part.lower()
            if lowered in {"domain", "application", "infrastructure"}:
                return lowered
        return "unknown"

    def _is_disallowed_import(self, layer: str, module: str) -> bool:
        """Return True when the import breaks the dependency rule."""

        prefixes = self._DISALLOWED_IMPORT_PREFIXES.get(layer, ())
        module_normalized = module.replace(" ", "")
        return any(module_normalized.startswith(prefix) for prefix in prefixes)

    def _is_repository_interface(self, class_node: ast.ClassDef) -> bool:
        """Determine whether the class represents a repository interface."""

        inherits_abc = any(
            (isinstance(base, ast.Name) and base.id == "ABC")
            or (isinstance(base, ast.Attribute) and base.attr == "ABC")
            for base in class_node.bases
        )
        has_abstract_methods = any(
            isinstance(stmt, ast.FunctionDef)
            and any(self._is_abstract_decorator(dec) for dec in stmt.decorator_list)
            for stmt in class_node.body
        )
        return inherits_abc or has_abstract_methods

    @staticmethod
    def _is_abstract_decorator(decorator: ast.AST) -> bool:
        """Identify @abstractmethod usage in decorator list."""

        if isinstance(decorator, ast.Name):
            return decorator.id == "abstractmethod"
        if isinstance(decorator, ast.Attribute):
            return decorator.attr == "abstractmethod"
        return False

    @staticmethod
    def _is_trivial_method(method_node: ast.FunctionDef) -> bool:
        """Heuristic to determine whether a method is behaviour-free."""

        meaningful_nodes = (ast.If, ast.For, ast.While, ast.Try, ast.With, ast.Raise, ast.Call, ast.Return)
        for stmt in method_node.body:
            if isinstance(stmt, meaningful_nodes):
                # Calls to super().__init__ only do not count as meaningful behaviour
                if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
                    func = stmt.value.func
                    if isinstance(func, ast.Attribute) and func.attr == "__init__":
                        continue
                return False
            if not isinstance(stmt, ast.Pass | ast.Expr):
                return False
            if isinstance(stmt, ast.Expr) and not isinstance(stmt.value, ast.Constant):
                # Non-trivial expression (e.g., assignment, method invocation)
                return False
        return True

    @staticmethod
    def _collect_class_names(source: str) -> list[str]:
        """Extract top-level class names from source."""

        try:
            tree = ast.parse(source or "")
        except SyntaxError:
            return []

        return [node.name for node in tree.body if isinstance(node, ast.ClassDef)]


# ------------------------------------------------------------------ #
# CLI helpers
# ------------------------------------------------------------------ #


def format_cli_output(result: DDDValidationResult) -> str:
    """Generate a human-readable CLI report."""

    score = result.calculate_compliance_score()

    sections: list[str] = []
    sections.append("🔍 DDD Validation Results")
    sections.append("-------------------------")
    sections.append(f"❌ Layer Violations: {len(result.layer_violations)}")
    sections.append(f"❌ Repository Violations: {len(result.repository_violations)}")
    sections.append(f"⚠️  Anemic Models: {len(result.anemic_warnings)}")
    sections.append(f"⚙️  Custom Rule Findings: {sum(len(v) for v in result.custom_rule_violations.values())}")
    sections.append(f"📊 Compliance Score: {score}")

    return "\n".join(sections)


def get_exit_code(result: DDDValidationResult, threshold: float = 90.0) -> int:
    """Return an exit code suitable for CLI integration."""

    score = result.calculate_compliance_score()
    if result.layer_violations or result.repository_violations:
        return 1
    if score < threshold:
        return 2
    return 0
