"""Composite tools for the code-intel MCP server.

Tools: review_change, explain_impact, suggest_tests.

These tools combine multiple analysis passes into single, agent-optimized
calls that reduce round-trips and produce richer context than calling
individual tools sequentially.
"""

from __future__ import annotations

import logging
import os
import subprocess

from attocode_intel._shared import (
    _get_context_mgr,
    _get_project_dir,
    _get_service,
    mcp,
)

logger = logging.getLogger(__name__)


@mcp.tool()
def change_context(mode: str = "working", base: str = "", depth: int = 2,
                   max_files: int = 24, max_symbols: int = 32,
                   files: list[str] | None = None, symbols: list[str] | None = None,
                   class_mode: str = "summary") -> dict:
    """Investigate a Git diff: changed symbols, current caller paths, import impact and candidate tests.

    Local full profile only. Modes: working (HEAD to working tree), staged,
    unstaged (includes untracked), branch (merge-base of base to working tree).
    Removed/index-only definitions retain historical source identity, without inferred callers.
    files: exact repository-relative paths (either rename side); symbols: exact simple/qualified
    changed names (either source version). Filters intersect, before caps. No glob matching.
    Classes default to a changed-member summary; class_mode="expand" collects their callers too.
    JSON includes revision/source hashes for `changes --check`; trimmed output is not a portable report.
    """
    from attocode_intel.change_investigation import change_context_data
    return change_context_data(_get_service(), mode, base, depth, max_files, max_symbols,
                               files, symbols, class_mode)


@mcp.tool()
def trace_flow(symbol_name: str, file_path: str | None = None, line: int | None = None,
               depth: int = 2, max_nodes: int = 32) -> dict:
    """Trace bounded static caller paths to a selected symbol, with call-site source and resolution evidence.

    Local Git workspace/full profile. Select file_path and definition start line if ambiguous.
    Depth 1..3, max_nodes 1..64. Same-name candidates remain unresolved, never followed.
    These are structural paths, not runtime execution order or complete control/data flow.
    """
    from attocode_intel.change_investigation import trace_flow_data
    return trace_flow_data(_get_service(), symbol_name, file_path, line, depth, max_nodes)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _git_modified_files(project_dir: str) -> list[str]:
    """Return list of git-modified file paths (relative to project root)."""
    try:
        result = subprocess.run(
            ["git", "diff", "--name-only", "HEAD"],
            cwd=project_dir,
            capture_output=True,
            text=True,
            timeout=15,
        )
        files = [f.strip() for f in result.stdout.splitlines() if f.strip()]
        if not files:
            # Also check staged files
            result = subprocess.run(
                ["git", "diff", "--name-only", "--cached"],
                cwd=project_dir,
                capture_output=True,
                text=True,
                timeout=15,
            )
            files = [f.strip() for f in result.stdout.splitlines() if f.strip()]
        return files
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
        logger.debug("Failed to get git-modified files: %s", exc)
        return []


def _find_test_files_by_convention(file_path: str, project_dir: str) -> list[str]:
    """Find test files matching naming conventions for a source file.

    Checks for: test_X.py, X_test.py, __tests__/X.test.ts, X.spec.ts, etc.
    """
    from pathlib import Path

    basename = Path(file_path).stem
    ext = Path(file_path).suffix
    parent = str(Path(file_path).parent)

    candidates = []

    if ext in (".py",):
        # Python conventions: test_X.py, X_test.py
        candidates.extend([
            os.path.join(parent, f"test_{basename}.py"),
            os.path.join(parent, f"{basename}_test.py"),
            # tests/ mirror directory
            file_path.replace("src/", "tests/", 1).replace(
                f"{basename}.py", f"test_{basename}.py"
            ),
            # tests/unit/ mirror
            "tests/unit/" + file_path.removeprefix("src/").replace(
                f"{basename}.py", f"test_{basename}.py"
            ),
            # tests/test_X.py flat layout
            f"tests/test_{basename}.py",
        ])
    elif ext in (".ts", ".tsx", ".js", ".jsx"):
        # JS/TS conventions
        test_ext = ".test" + ext
        spec_ext = ".spec" + ext
        candidates.extend([
            os.path.join(parent, f"{basename}{test_ext}"),
            os.path.join(parent, f"{basename}{spec_ext}"),
            os.path.join(parent, "__tests__", f"{basename}{test_ext}"),
            os.path.join(parent, "__tests__", f"{basename}{spec_ext}"),
        ])
    elif ext in (".go",):
        # Go convention: X_test.go in same directory
        candidates.append(os.path.join(parent, f"{basename}_test.go"))
    elif ext in (".rs",):
        # Rust: tests/X.rs or inline #[cfg(test)] (can't find inline from path)
        candidates.append(os.path.join(parent, "tests", f"{basename}.rs"))
    elif ext in (".java", ".kt"):
        # Java/Kotlin: mirror src/main -> src/test
        candidates.append(
            file_path.replace("src/main/", "src/test/", 1).replace(
                f"{basename}{ext}", f"{basename}Test{ext}"
            )
        )

    # Deduplicate and filter to existing files
    seen: set[str] = set()
    existing: list[str] = []
    for c in candidates:
        c = os.path.normpath(c)
        if c in seen:
            continue
        seen.add(c)
        full = os.path.join(project_dir, c)
        if os.path.isfile(full):
            existing.append(c)

    return existing


def _test_paths(project_dir: str) -> list[str]:
    """Tracked test source files. The index can leave out tests of a large repository."""
    from pathlib import PurePosixPath

    from attocode_intel._internal.integrations.context.codebase_context import EXTENSION_LANGUAGES
    from attocode_intel.test_ranking import is_test_path

    try:
        listed = subprocess.run(
            ["git", "ls-files", "-z"],
            cwd=project_dir, capture_output=True, text=True, timeout=30, check=True,
        ).stdout.split("\0")
    except (OSError, subprocess.SubprocessError):
        listed = []
    if not any(listed):  # Not a git repository, or nothing tracked yet.
        try:
            listed = [f.relative_path for f in _get_context_mgr()._files]
        except Exception:
            return []
    data = {"data", "yaml", "json", "toml", "markdown", "html", "css", "scss"}
    return [path for path in listed if path
            and EXTENSION_LANGUAGES.get(PurePosixPath(path).suffix, "data") not in data and is_test_path(path)]


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------


@mcp.tool()
def review_change(
    files: list[str] | None = None,
    mode: str = "full",
) -> str:
    """Review changed files for security issues, rule violations and convention problems in one report.

    Args:
        files: Files to review (default: the files that git reports as changed).
        mode: "quick" (security only) or "full".
    """
    project_dir = _get_project_dir()

    # Resolve files to review
    if files is None:
        files = _git_modified_files(project_dir)
    if not files:
        return "No files to review. Provide file paths or ensure git has modified files."

    if mode not in ("quick", "full"):
        return f"Error: Invalid mode '{mode}'. Use 'quick' or 'full'."

    svc = _get_service()
    report_sections: list[str] = []
    total_findings = 0

    # --- Security scan ---
    security_text = ""
    try:
        # Scan each file's directory to scope the results
        scanned_paths: set[str] = set()
        for f in files:
            scan_path = os.path.dirname(f) or ""
            if scan_path not in scanned_paths:
                scanned_paths.add(scan_path)

        # Run a single security scan (scoped to project)
        security_text = svc.security_scan(mode="full", path="")
    except Exception as exc:
        security_text = f"Security scan error: {exc}"

    if security_text:
        # Filter findings to only mention changed files
        relevant_lines: list[str] = []
        for line in security_text.splitlines():
            # Include header/summary lines and lines mentioning changed files
            if any(f in line for f in files) or not line.startswith("  "):
                relevant_lines.append(line)
        filtered_security = "\n".join(relevant_lines) if relevant_lines else security_text

        # Count findings (lines with severity indicators)
        finding_markers = ("HIGH", "MEDIUM", "LOW", "CRITICAL", "WARNING")
        sec_findings = sum(
            1 for line in filtered_security.splitlines()
            if any(m in line.upper() for m in finding_markers)
        )
        total_findings += sec_findings
        report_sections.append(f"## Security ({sec_findings} finding(s))\n\n{filtered_security}")

    # --- Rule-based analysis (full mode only) ---
    if mode == "full":
        rules_text = ""
        try:
            rules_text = svc.analyze(
                files=files, min_confidence=0.5, max_findings=20,
            )
        except Exception as exc:
            rules_text = f"Rule analysis error: {exc}"

        if rules_text and "No findings" not in rules_text:
            rule_findings = sum(
                1 for line in rules_text.splitlines()
                if any(m in line for m in ("[CRITICAL]", "[HIGH]", "[MEDIUM]", "[LOW]", "[INFO]"))
            )
            total_findings += rule_findings
            report_sections.append(f"## Rule Analysis ({rule_findings} finding(s))\n\n{rules_text}")

    # --- Conventions check (full mode only) ---
    if mode == "full":
        conventions_text = ""
        try:
            conventions_text = svc.conventions(sample_size=25, path="")
        except Exception as exc:
            conventions_text = f"Conventions check error: {exc}"

        if conventions_text:
            report_sections.append(f"## Conventions\n\n{conventions_text}")

    # --- Build report ---
    file_list = "\n".join(f"  - {f}" for f in files)
    header = (
        f"# Change Review Report ({mode} mode)\n\n"
        f"Files reviewed ({len(files)}):\n{file_list}\n"
    )

    body = "\n\n".join(report_sections) if report_sections else "No findings."

    # Analysis failures must not be presented as successful verification.
    incomplete = any("error:" in section.lower() for section in report_sections)
    # Assessment
    if incomplete:
        assessment = "Review incomplete: one or more analysis passes failed. Resolve the reported errors before relying on this review."
    elif total_findings == 0:
        assessment = "No findings were reported by the completed checks. This does not establish that the change is safe."
    elif total_findings <= 3:
        assessment = f"{total_findings} finding(s) detected. Review recommended before merging."
    else:
        assessment = (
            f"{total_findings} finding(s) detected. "
            "Careful review required -- multiple issues found."
        )

    summary = f"\n\n## Summary\n\n{assessment}"

    return header + "\n" + body + summary


@mcp.tool()
def explain_impact(
    files: list[str],
    depth: int = 3,
) -> str:
    """Explain the blast radius of changing files with rich context.

    Combines impact analysis, community detection, and temporal coupling
    to provide a comprehensive understanding of what would be affected
    by changes to the specified files.

    Args:
        files: File paths to analyze (relative to project root).
        depth: Maximum depth for dependency traversal (default 3).

    Returns:
        Narrative explanation of the change impact.
    """
    if not files:
        return "Error: No files specified for impact analysis."

    if depth < 1:
        depth = 1
    elif depth > 5:
        depth = 5

    svc = _get_service()
    sections: list[str] = []

    # --- Impact analysis ---
    impact_text = ""
    try:
        impact_text = svc.impact_analysis(files)
    except Exception as exc:
        impact_text = f"Impact analysis error: {exc}"

    if impact_text:
        sections.append(f"## Direct Impact\n\n{impact_text}")

    # --- Community detection ---
    community_text = ""
    try:
        community_text = svc.community_detection(
            min_community_size=2, max_communities=10,
        )
    except Exception as exc:
        community_text = f"Community detection error: {exc}"

    if community_text:
        # Extract relevant community info for the target files
        relevant_community_lines: list[str] = []
        in_relevant_community = False
        for line in community_text.splitlines():
            if any(f in line for f in files):
                in_relevant_community = True
            if in_relevant_community:
                relevant_community_lines.append(line)
            # Reset at community boundaries
            if in_relevant_community and line.strip() == "":
                in_relevant_community = False

        if relevant_community_lines:
            filtered_community = "\n".join(relevant_community_lines)
            sections.append(
                f"## Module Community\n\n"
                f"The changed files belong to these communities:\n\n"
                f"{filtered_community}"
            )
        else:
            sections.append(f"## Module Community\n\n{community_text}")

    # --- Temporal coupling ---
    coupling_sections: list[str] = []
    high_coupling_files: list[tuple[str, str]] = []  # (file, coupling_info)
    for f in files:
        try:
            coupling_text = svc.change_coupling(
                file=f, days=90, min_coupling=0.3, top_k=10,
            )
            if coupling_text and "no " not in coupling_text.lower():
                coupling_sections.append(f"### {f}\n{coupling_text}")
                # Track high-coupling pairs for risk assessment
                for line in coupling_text.splitlines():
                    if any(
                        marker in line
                        for marker in ("0.8", "0.9", "1.0", "score: 0.7")
                    ):
                        high_coupling_files.append((f, line.strip()))
        except Exception as exc:
            coupling_sections.append(f"### {f}\nTemporal coupling error: {exc}")

    if coupling_sections:
        sections.append(
            "## Temporal Coupling (git co-change history)\n\n"
            + "\n\n".join(coupling_sections)
        )

    # --- Risk assessment ---
    risk_level = "LOW"
    risk_reasons: list[str] = []

    # Check impact breadth
    if impact_text:
        affected_count = impact_text.count("\n")
        if affected_count > 20:
            risk_level = "HIGH"
            risk_reasons.append(
                f"Large blast radius: {affected_count}+ files potentially affected"
            )
        elif affected_count > 10:
            risk_level = "MEDIUM"
            risk_reasons.append(
                f"Moderate blast radius: ~{affected_count} files potentially affected"
            )

    # Check temporal coupling
    if high_coupling_files:
        if risk_level == "LOW":
            risk_level = "MEDIUM"
        risk_reasons.append(
            f"{len(high_coupling_files)} file(s) with high temporal coupling "
            "(often change together -- verify they don't need updates too)"
        )

    # Check if multiple communities are affected
    if len(files) > 1:
        risk_reasons.append(
            f"Changes span {len(files)} files -- cross-cutting changes "
            "may affect multiple modules"
        )

    risk_summary = (
        f"## Risk Assessment: {risk_level}\n\n"
        + ("\n".join(f"  - {r}" for r in risk_reasons) if risk_reasons else "  - No elevated risk factors detected.")
    )
    sections.append(risk_summary)

    # --- Compose narrative ---
    file_list = ", ".join(f"`{f}`" for f in files)
    header = f"# Impact Analysis for {file_list}\n"

    return header + "\n\n".join(sections)


@mcp.tool()
def suggest_tests(files: list[str], symbol_name: str | None = None, task_hint: str | None = None) -> str:
    """Suggest candidate tests using imports and syntax; optionally focus on a symbol and task."""
    if not files:
        return "Error: No files specified. Provide a list of changed files."
    return format_test_suggestions(suggest_tests_data(files, symbol_name=symbol_name, task_hint=task_hint))


def suggest_tests_data(files: list[str], *, symbol_name: str | None = None,
                       task_hint: str | None = None, linked_reference_files=None) -> dict:
    """Reusable evidence; callers format or budget it without repeating graph traversal."""
    project_dir = _get_project_dir()

    # Collect test suggestions with priorities
    # Priority 1: Direct test files (by naming convention)
    # Priority 2: Test files that import the changed module
    # Priority 3: Tests for dependent modules
    suggestions: dict[str, dict] = {}  # path -> {priority, reasons}
    distances: dict[str, int] = {}

    def _add_suggestion(path: str, priority: int, reason: str) -> None:
        """Add or update a test suggestion."""
        if path in suggestions:
            existing = suggestions[path]
            existing["priority"] = min(existing["priority"], priority)
            if reason not in existing["reasons"]:
                existing["reasons"].append(reason)
        else:
            suggestions[path] = {"priority": priority, "reasons": [reason]}

    # --- Priority 1: Convention-based test files ---
    for f in files:
        convention_tests = _find_test_files_by_convention(f, project_dir)
        for test_file in convention_tests:
            _add_suggestion(
                test_file,
                priority=1,
                reason=f"Direct test file for `{f}`",
            )

    # Test files named for a changed file, anywhere in the repository.
    from attocode_intel.test_ranking import folder_overlap, is_test_path, name_matched_tests
    test_paths = _test_paths(project_dir)
    for f in files:
        for test_file, priority, reason in name_matched_tests(f, test_paths):
            _add_suggestion(test_file, priority, reason)

    # --- Priority 2: Import-based discovery ---
    try:
        ctx = _get_context_mgr()
        dep_graph = getattr(ctx, "dependency_graph", None) or getattr(ctx, "_dep_graph", None)

        if dep_graph is not None:
            for f in files:
                # Find files that import the changed file (dependents)
                try:
                    importers = dep_graph.get_importers(f)
                except (AttributeError, KeyError):
                    try:
                        importers = dep_graph.get_reverse_deps(f)
                    except (AttributeError, KeyError):
                        importers = set()

                for importer in importers:
                    # Check if the importer is a test file
                    is_test = is_test_path(importer)
                    if is_test:
                        _add_suggestion(
                            importer,
                            priority=2,
                            reason=f"Imports changed module `{f}`",
                        )

            # --- Priority 3: Tests for dependent modules ---
            for f in files:
                try:
                    importers = dep_graph.get_importers(f)
                except (AttributeError, KeyError):
                    try:
                        importers = dep_graph.get_reverse_deps(f)
                    except (AttributeError, KeyError):
                        importers = set()

                for importer in importers:
                    is_test = is_test_path(importer)
                    if not is_test:
                        # Find tests for this dependent module
                        dep_tests = _find_test_files_by_convention(
                            importer, project_dir
                        )
                        for test_file in dep_tests:
                            _add_suggestion(
                                test_file,
                                priority=3,
                                reason=f"Tests dependent module `{importer}` (which imports `{f}`)",
                            )
    except Exception as exc:
        logger.debug("Import-based test discovery failed: %s", exc)

    # Follow actual reverse-import paths beyond immediate importers.
    try:
        from collections import deque
        from pathlib import Path
        ctx = _get_context_mgr()
        graph = ctx.dependency_graph
        for changed in files:
            queue = deque([(changed, 0)])
            seen = {changed}
            while queue:
                source, depth = queue.popleft()
                for target in sorted(graph.get_importers(source)) if graph else []:
                    if target in seen:
                        continue
                    seen.add(target)
                    queue.append((target, depth + 1))
                    if is_test_path(target):
                        _add_suggestion(target, 2 if depth == 0 else 3,
                                        f"Reverse import path from `{changed}` ({depth + 1} edges)")
                        distances[target] = min(distances.get(target, depth + 1), depth + 1)
            path = Path(project_dir) / changed
            if path.suffix == ".rs" and path.is_file():
                content = path.read_text(encoding="utf-8")
                if "#[test]" in content or "#[cfg(test)]" in content:
                    _add_suggestion(changed, 1, "Contains Rust inline test declarations; select the owning Cargo package")
    except (OSError, AttributeError) as exc:
        logger.debug("Indirect test discovery incomplete: %s", exc)

    if symbol_name or task_hint:
        from attocode_intel._shared import _get_ast_service, _get_service
        from attocode_intel.test_ranking import rank_symbol_tests
        service = _get_service()
        task_scores = (service._task_file_scores(task_hint)
                       if task_hint and hasattr(service, "_task_file_scores") else {})
        return _with_limits({"files": files, "symbol_name": symbol_name, "task_hint": task_hint,
                "candidates": rank_symbol_tests(_get_ast_service(), suggestions, files, symbol_name,
                                                task_hint, distances, linked_reference_files,
                                                relevance_by_file=task_scores),
                "ranking": "Selected-symbol links, reverse-import distance, then lexical test names; verify candidates",
                "absence_proven": False})

    # Prefer tests with concrete syntax references to changed definitions.
    symbol_hits: dict[str, int] = {}
    representatives: set[str] = set()
    try:
        from attocode_intel._shared import _get_ast_service
        ast = _get_ast_service()
        for changed in files:
            for symbol in ast.get_file_symbols(changed):
                if symbol.kind == "variable":
                    continue
                matches = {ref.file_path for ref in ast.get_callers(symbol.qualified_name)
                           if ref.file_path in suggestions}
                if matches:
                    representatives.add(min(matches, key=lambda path: (
                        symbol.name.lower() not in path.lower(), len(path), path)))
                for path in matches:
                    symbol_hits[path] = symbol_hits.get(path, 0) + 1
                    _add_suggestion(path, suggestions[path]["priority"],
                                    f"Syntax reference to `{symbol.qualified_name}`; verify ambiguous matches")
    except (AttributeError, OSError) as exc:
        logger.debug("Test reference ranking incomplete: %s", exc)

    # Sort by evidence, then put repository tests before documentation examples, then nearest first.
    def nearness(path):
        return max(folder_overlap(path, changed) for changed in files) if files else 0

    sorted_suggestions = sorted(
        suggestions.items(),
        key=lambda kv: (kv[1]["priority"], kv[0] not in representatives, -symbol_hits.get(kv[0], 0),
                        kv[0].startswith("docs"), -nearness(kv[0]), kv[0]),
    )

    return _with_limits({"files": files, "candidates": [
        {"file_path": path, "priority": info["priority"], "reasons": info["reasons"][:3]}
        for path, info in sorted_suggestions
    ], "absence_proven": False})


TEST_NOTE = ("These candidates come only from file names and imports. The list is not complete: "
             "other tests near the changed code can also check this change, so search for them too.")


def _with_limits(result: dict) -> dict:
    """In R4 Stage 1, agents named only the listed tests and stopped searching. Say what the list is."""
    best = min((row["priority"] for row in result["candidates"]), default=None)
    evidence = "none" if best is None else {1: "direct", 2: "imports", 3: "indirect"}.get(best, "names")
    return {**result, "evidence": evidence, "note": TEST_NOTE}


def format_test_suggestions(data: dict) -> str:
    files = data["files"]
    suggestions = {row["file_path"]: row for row in data["candidates"]}
    sorted_suggestions = list(suggestions.items())
    # --- Format output ---
    if not suggestions:
        # Fallback: suggest running all tests
        lines = [
            "# Test Suggestions\n",
            "No specific test files found for the changed files.",
            "",
            "Changed files:",
        ]
        for f in files:
            lines.append(f"  - {f}")
        lines.extend([
            "",
            "Recommendations:",
            "  - Run the full test suite",
            "  - Check if these files have inline tests (e.g., Rust #[cfg(test)])",
            "  - Consider creating test files for untested modules",
        ])
        return "\n".join(lines)

    priority_labels = {
        1: "DIRECT",
        2: "IMPORTS",
        3: "INDIRECT",
        4: "NAME",
    }

    lines = [
        "# Test Suggestions\n",
        f"Found {len(sorted_suggestions)} test file(s) for "
        f"{len(files)} changed file(s).\n",
    ]

    current_priority = 0
    for test_path, info in sorted_suggestions:
        priority = info["priority"]
        if priority != current_priority:
            current_priority = priority
            label = priority_labels.get(priority, f"P{priority}")
            lines.append(f"\n## Priority {priority} ({label})\n")

        lines.append(f"  {test_path}")
        for reason in info["reasons"][:3]:
            lines.append(f"    - {reason}")

    # Summary
    by_priority: dict[int, int] = {}
    for _, info in sorted_suggestions:
        by_priority[info["priority"]] = by_priority.get(info["priority"], 0) + 1

    lines.append("\n## Summary\n")
    for p in sorted(by_priority):
        label = priority_labels.get(p, f"P{p}")
        lines.append(f"  {label}: {by_priority[p]} test file(s)")

    lines.append(
        "\nThese are test candidates based on naming and static relationships. "
        "They are not complete coverage or proof that a change is safe."
    )

    return "\n".join(lines)


@mcp.tool()
def architecture_drift(
    config_path: str = "",
) -> str:
    """Detect architecture boundary violations in the codebase.

    Compares actual file dependencies against rules defined in
    .attocode/architecture.yaml. Reports layering violations,
    unauthorized imports, and circular dependencies.

    Args:
        config_path: Path to architecture config (default: .attocode/architecture.yaml).
    """
    from attocode_intel._internal.integrations.context.architecture_drift import (
        check_drift,
        format_report,
    )

    project_dir = _get_project_dir()

    # If a custom config_path is given, verify it exists
    if config_path and not os.path.isfile(config_path):
        return (
            f"Error: Architecture config not found at '{config_path}'.\n"
            "Create a .attocode/architecture.yaml file defining your layers and rules.\n"
            "See documentation for the expected YAML format."
        )

    # Check that the default config exists when no override is given
    if not config_path:
        default_path = os.path.join(project_dir, ".attocode", "architecture.yaml")
        if not os.path.isfile(default_path):
            return (
                "No architecture config found at .attocode/architecture.yaml.\n\n"
                "Create this file to define your architecture boundaries. Example:\n\n"
                "  layers:\n"
                '    - name: presentation\n'
                '      paths: ["src/api/", "src/routes/"]\n'
                '    - name: business\n'
                '      paths: ["src/services/", "src/domain/"]\n'
                '    - name: data\n'
                '      paths: ["src/models/", "src/repositories/"]\n\n'
                "  rules:\n"
                "    - from: presentation\n"
                "      to: [business]\n"
                "      deny: [data]\n"
                "    - from: business\n"
                "      to: [data]\n"
                "      deny: [presentation]\n"
            )

    try:
        report = check_drift(project_dir)
        return format_report(report)
    except Exception as exc:
        logger.exception("Architecture drift check failed")
        return f"Error running architecture drift check: {exc}"
