"""Tests for architecture analysis CLI commands.

Covers ~50 tests for:
- map: project skeleton overview
- layers: topological layer detection
- clusters: community detection
- coupling: temporal coupling analysis
- entry-points: exported API surface
- patterns: architectural pattern detection
- safe-zones: safe refactoring boundaries
- visualize: graph visualization
"""

from __future__ import annotations

import sys
from pathlib import Path

import pytest
from click.testing import CliRunner

sys.path.insert(0, str(Path(__file__).parent))
from conftest import assert_json_envelope, invoke_cli, parse_json_output

# ---------------------------------------------------------------------------
# Override cli_runner fixture to handle Click 8.2+ (mix_stderr removed)
# ---------------------------------------------------------------------------


@pytest.fixture
def cli_runner():
    """Provide a Click CliRunner (W840: Click 8.3+ dropped mix_stderr kwarg)."""
    return CliRunner()


# W414: All architecture-command tests in this file are read-only against
# the indexed project (map/layers/clusters/coupling/entry-points/patterns/
# safe-zones/visualize). Re-indexing on every test costs ~2s x 63. Override
# at module scope here so the project is built and indexed once per worker.
# Mirrors W346's pattern in test_json_contracts.
@pytest.fixture(scope="module")
def indexed_project(tmp_path_factory):
    """Module-scoped indexed Python project for read-only architecture tests."""
    import textwrap

    proj = tmp_path_factory.mktemp("commands_architecture_proj")
    (proj / ".gitignore").write_text(".roam/\n", encoding="utf-8")
    src = proj / "src"
    src.mkdir()

    (src / "models.py").write_text(
        textwrap.dedent(
            '''\
            class User:
                """A user model."""
                def __init__(self, name, email):
                    self.name = name
                    self.email = email

                def display_name(self):
                    return self.name.title()

                def validate_email(self):
                    return "@" in self.email


            class Admin(User):
                """An admin user."""
                def __init__(self, name, email, role="admin"):
                    super().__init__(name, email)
                    self.role = role

                def promote(self, user):
                    pass
            '''
        ),
        encoding="utf-8",
    )
    (src / "service.py").write_text(
        textwrap.dedent(
            '''\
            from models import User, Admin


            def create_user(name, email):
                """Create a new user."""
                user = User(name, email)
                if not user.validate_email():
                    raise ValueError("Invalid email")
                return user


            def get_display(user):
                """Get display name."""
                return user.display_name()


            def unused_helper():
                """This function is never called (dead code)."""
                return 42
            '''
        ),
        encoding="utf-8",
    )
    (src / "utils.py").write_text(
        textwrap.dedent(
            '''\
            def format_name(first, last):
                """Format a full name."""
                return f"{first} {last}"


            def parse_email(raw):
                """Parse an email address."""
                if "@" not in raw:
                    return None
                parts = raw.split("@")
                return {"user": parts[0], "domain": parts[1]}


            UNUSED_CONSTANT = "never_referenced"
            '''
        ),
        encoding="utf-8",
    )

    import os
    import subprocess

    env = {
        **os.environ,
        "GIT_AUTHOR_NAME": "test",
        "GIT_AUTHOR_EMAIL": "t@t.com",
        "GIT_COMMITTER_NAME": "test",
        "GIT_COMMITTER_EMAIL": "t@t.com",
    }
    subprocess.run(["git", "init"], cwd=str(proj), capture_output=True)
    subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=str(proj), capture_output=True)
    subprocess.run(["git", "config", "user.name", "Test"], cwd=str(proj), capture_output=True)
    subprocess.run(["git", "add", "."], cwd=str(proj), capture_output=True)
    subprocess.run(
        ["git", "commit", "-m", "init"],
        cwd=str(proj),
        capture_output=True,
        env=env,
    )

    from roam.cli import cli

    runner = CliRunner()
    old_cwd = os.getcwd()
    try:
        os.chdir(str(proj))
        result = runner.invoke(cli, ["index"], catch_exceptions=False)
    finally:
        os.chdir(old_cwd)
    assert result.exit_code == 0, f"roam index failed:\n{result.output}"
    return proj


# ============================================================================
# map command
# ============================================================================


class TestMap:
    """Tests for `roam map` -- project skeleton overview."""

    def test_map_shows_files(self, cli_runner, indexed_project, monkeypatch):
        """map output mentions file count."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project)
        assert result.exit_code == 0, f"map failed:\n{result.output}"
        assert "Files:" in result.output

    def test_map_shows_edges(self, cli_runner, indexed_project, monkeypatch):
        """map output shows edge count (import relationships exist)."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project)
        assert result.exit_code == 0
        assert "Edges:" in result.output

    def test_map_shows_symbols(self, cli_runner, indexed_project, monkeypatch):
        """map output shows symbol count."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project)
        assert result.exit_code == 0
        assert "Symbols:" in result.output

    def test_map_shows_languages(self, cli_runner, indexed_project, monkeypatch):
        """map output includes language breakdown."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project)
        assert result.exit_code == 0
        assert "Languages:" in result.output

    def test_map_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "map")
        assert_json_envelope(data, "map")

    def test_map_json_has_files(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes files count."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "map")
        assert "files" in data["summary"]
        assert data["summary"]["files"] > 0

    def test_map_json_has_top_symbols(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes top_symbols array."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "map")
        assert "top_symbols" in data
        assert isinstance(data["top_symbols"], list)

    def test_map_json_has_directories(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes directories array."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "map")
        assert "directories" in data
        assert isinstance(data["directories"], list)

    def test_map_count_option(self, cli_runner, indexed_project, monkeypatch):
        """map -n 5 limits symbol display."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map", "-n", "5"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_map_budget_option(self, cli_runner, indexed_project, monkeypatch):
        """map --budget limits output by approximate token count."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["map", "--budget", "200"], cwd=indexed_project)
        assert result.exit_code == 0


# ============================================================================
# layers command
# ============================================================================


class TestLayers:
    """Tests for `roam layers` -- topological layer detection."""

    def test_layers_runs(self, cli_runner, indexed_project, monkeypatch):
        """layers exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["layers"], cwd=indexed_project)
        assert result.exit_code == 0, f"layers failed:\n{result.output}"

    def test_layers_shows_layers(self, cli_runner, indexed_project, monkeypatch):
        """Output contains layer numbers or layer info."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["layers"], cwd=indexed_project)
        assert result.exit_code == 0
        output = result.output.lower()
        assert "layer" in output

    def test_layers_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["layers"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "layers")
        assert_json_envelope(data, "layers")

    def test_layers_json_has_layer_data(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes layers array and violation count."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["--detail", "layers"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "layers")
        assert "layers" in data
        assert isinstance(data["layers"], list)
        assert "violations" in data["summary"]

    def test_layers_json_total_layers(self, cli_runner, indexed_project, monkeypatch):
        """JSON summary includes total_layers count."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["layers"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "layers")
        assert "total_layers" in data["summary"]
        assert isinstance(data["summary"]["total_layers"], int)

    def test_layers_models_lower(self, project_factory, cli_runner, monkeypatch):
        """Symbols should be assigned to different layers based on call direction.

        Layer 0 = no incoming edges (callers/entry points).
        Higher layers = deeper callees.
        create_user calls User, so create_user is layer 0, User is layer 1+.
        """
        proj = project_factory(
            {
                "models.py": ("class User:\n    def __init__(self, name):\n        self.name = name\n"),
                "service.py": ("from models import User\n\ndef create_user(name):\n    return User(name)\n"),
            }
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["layers"], cwd=proj, json_mode=True)
        data = parse_json_output(result, "layers")
        if data.get("layers"):
            layer_numbers = {}
            for layer_info in data["layers"]:
                for sym in layer_info.get("symbols", []):
                    if sym["name"] == "User":
                        layer_numbers["User"] = layer_info["layer"]
                    if sym["name"] == "create_user":
                        layer_numbers["create_user"] = layer_info["layer"]
            if "User" in layer_numbers and "create_user" in layer_numbers:
                # create_user (caller, no incoming edges) should be layer 0
                # User (callee) should be in a higher or equal layer
                assert layer_numbers["create_user"] <= layer_numbers["User"], (
                    f"create_user (layer {layer_numbers['create_user']}) should be <= "
                    f"User (layer {layer_numbers['User']})"
                )

    def test_layers_shows_violations_section(self, cli_runner, indexed_project, monkeypatch):
        """Text output includes a Violations section."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["layers"], cwd=indexed_project)
        assert result.exit_code == 0
        assert "Violations" in result.output or "layer" in result.output.lower()


# ============================================================================
# clusters command
# ============================================================================


class TestClusters:
    """Tests for `roam clusters` -- community detection."""

    def test_clusters_runs(self, cli_runner, indexed_project, monkeypatch):
        """clusters exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["clusters"], cwd=indexed_project)
        assert result.exit_code == 0, f"clusters failed:\n{result.output}"

    def test_clusters_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["clusters"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "clusters")
        assert_json_envelope(data, "clusters")

    def test_clusters_shows_groups(self, cli_runner, indexed_project, monkeypatch):
        """Output has cluster groupings or mentions clusters."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["clusters"], cwd=indexed_project)
        assert result.exit_code == 0
        output = result.output.lower()
        assert "cluster" in output

    def test_clusters_json_has_clusters_array(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes clusters array."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["--detail", "clusters"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "clusters")
        assert "clusters" in data
        assert isinstance(data["clusters"], list)

    def test_clusters_json_summary(self, cli_runner, indexed_project, monkeypatch):
        """JSON summary includes cluster count and modularity."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["clusters"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "clusters")
        summary = data["summary"]
        assert "clusters" in summary
        assert "modularity_q" in summary

    def test_clusters_min_size_option(self, cli_runner, indexed_project, monkeypatch):
        """--min-size option is accepted."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["clusters", "--min-size", "1"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_clusters_mismatches_section(self, cli_runner, indexed_project, monkeypatch):
        """Text output includes a directory mismatches section."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["clusters"], cwd=indexed_project)
        assert result.exit_code == 0
        output = result.output.lower()
        assert "mismatch" in output or "cluster" in output


# ============================================================================
# coupling command
# ============================================================================


class TestCoupling:
    """Tests for `roam coupling` -- temporal coupling analysis."""

    def test_coupling_runs(self, cli_runner, indexed_project, monkeypatch):
        """coupling exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["coupling"], cwd=indexed_project)
        assert result.exit_code == 0, f"coupling failed:\n{result.output}"

    def test_coupling_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["coupling"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "coupling")
        assert_json_envelope(data, "coupling")

    def test_coupling_detects_pairs(self, cli_runner, indexed_project, monkeypatch):
        """Output shows coupled file pairs or indicates no data."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["coupling"], cwd=indexed_project)
        assert result.exit_code == 0
        output = result.output.lower()
        # Should either show coupling data or say no data
        assert "co-change" in output or "coupling" in output or "no " in output

    def test_coupling_json_has_pairs(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes pairs array (may be empty for small project)."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["coupling"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "coupling")
        assert "pairs" in data or "summary" in data

    def test_coupling_count_option(self, cli_runner, indexed_project, monkeypatch):
        """coupling -n 5 is accepted."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["coupling", "-n", "5"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_coupling_models_service(self, project_factory, cli_runner, monkeypatch):
        """In a project with co-change history, models and service appear coupled."""
        proj = project_factory(
            {
                "models.py": ("class User:\n    def __init__(self, name):\n        self.name = name\n"),
                "service.py": ("from models import User\n\ndef create_user(name):\n    return User(name)\n"),
            },
            extra_commits=[
                (
                    {
                        "models.py": (
                            "class User:\n"
                            "    def __init__(self, name, email):\n"
                            "        self.name = name\n"
                            "        self.email = email\n"
                        ),
                        "service.py": (
                            "from models import User\n\ndef create_user(name, email):\n    return User(name, email)\n"
                        ),
                    },
                    "add email field",
                ),
                (
                    {
                        "models.py": (
                            "class User:\n"
                            "    def __init__(self, name, email, role='user'):\n"
                            "        self.name = name\n"
                            "        self.email = email\n"
                            "        self.role = role\n"
                        ),
                        "service.py": (
                            "from models import User\n"
                            "\n"
                            "def create_user(name, email, role='user'):\n"
                            "    return User(name, email, role)\n"
                        ),
                    },
                    "add role field",
                ),
            ],
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["coupling"], cwd=proj)
        assert result.exit_code == 0
        output = result.output.lower()
        # Should find coupling pairs or at least run successfully
        # In a small project with 3 co-commits, coupling data may exist
        assert "co-change" in output or "coupling" in output or "no " in output


# ============================================================================
# entry-points command
# ============================================================================


class TestEntryPoints:
    """Tests for `roam entry-points` -- exported API surface."""

    def test_entry_points_runs(self, cli_runner, indexed_project, monkeypatch):
        """entry-points exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points"], cwd=indexed_project)
        assert result.exit_code == 0, f"entry-points failed:\n{result.output}"

    def test_entry_points_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "entry-points")
        assert_json_envelope(data, "entry-points")

    def test_entry_points_finds_exports(self, cli_runner, indexed_project, monkeypatch):
        """Finds exported functions/classes (symbols with no callers)."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points"], cwd=indexed_project)
        assert result.exit_code == 0
        output = result.output
        # Should find at least some entry points or say none found
        assert "Entry" in output or "entry" in output or "No entry" in output

    def test_entry_points_json_has_entries(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes entry_points array."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "entry-points")
        assert "entry_points" in data
        assert isinstance(data["entry_points"], list)

    def test_entry_points_json_total(self, cli_runner, indexed_project, monkeypatch):
        """JSON summary includes total count."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "entry-points")
        assert "total" in data["summary"]

    def test_entry_points_limit_option(self, cli_runner, indexed_project, monkeypatch):
        """--limit option is accepted."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points", "--limit", "5"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_entry_points_protocol_filter(self, cli_runner, indexed_project, monkeypatch):
        """--protocol filter is accepted (may return empty set)."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points", "--protocol", "Export"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_entry_points_coverage_field(self, cli_runner, indexed_project, monkeypatch):
        """JSON entry points include coverage_pct when entries exist."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["entry-points"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "entry-points")
        if data["entry_points"]:
            ep = data["entry_points"][0]
            assert "coverage_pct" in ep


# ============================================================================
# patterns command
# ============================================================================


class TestPatterns:
    """Tests for `roam patterns` -- architectural pattern detection."""

    def test_patterns_runs(self, cli_runner, indexed_project, monkeypatch):
        """patterns exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["patterns"], cwd=indexed_project)
        assert result.exit_code == 0, f"patterns failed:\n{result.output}"

    def test_patterns_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["patterns"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "patterns")
        assert_json_envelope(data, "patterns")

    def test_patterns_json_has_patterns(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes patterns dict."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["patterns"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "patterns")
        assert "patterns" in data
        assert isinstance(data["patterns"], dict)

    def test_patterns_json_summary(self, cli_runner, indexed_project, monkeypatch):
        """JSON summary includes total_patterns and pattern_types."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["patterns"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "patterns")
        assert "total_patterns" in data["summary"]
        assert "pattern_types" in data["summary"]

    def test_patterns_filter_option(self, cli_runner, indexed_project, monkeypatch):
        """--pattern filter is accepted."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["patterns", "--pattern", "factory"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_patterns_detects_factory(self, project_factory, cli_runner, monkeypatch):
        """A project with factory naming should detect factory pattern."""
        proj = project_factory(
            {
                "models.py": ("class Widget:\n    def __init__(self, kind):\n        self.kind = kind\n"),
                "factory.py": (
                    "from models import Widget\n"
                    "\n"
                    "def create_widget(kind):\n"
                    "    return Widget(kind)\n"
                    "\n"
                    "def create_special_widget():\n"
                    "    return Widget('special')\n"
                ),
            }
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["patterns"], cwd=proj, json_mode=True)
        data = parse_json_output(result, "patterns")
        # Factory pattern detection is name-based; create_* should match
        if data["summary"]["total_patterns"] > 0:
            assert "factory" in data["patterns"]


# ============================================================================
# safe-zones command
# ============================================================================


class TestSafeZones:
    """Tests for `roam safe-zones` -- safe refactoring boundaries."""

    def test_safe_zones_runs(self, cli_runner, indexed_project, monkeypatch):
        """safe-zones exits 0 with a valid target."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "User"], cwd=indexed_project)
        assert result.exit_code == 0, f"safe-zones failed:\n{result.output}"

    def test_safe_zones_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "User"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "safe-zones")
        assert_json_envelope(data, "safe-zones")

    def test_safe_zones_json_has_zone(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes zone classification."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "User"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "safe-zones")
        assert "zone" in data["summary"]
        zone = data["summary"]["zone"]
        assert zone in ("ISOLATED", "CONTAINED", "EXPOSED")

    def test_safe_zones_json_has_symbols(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes internal and boundary symbol lists."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "User"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "safe-zones")
        assert "internal_symbols" in data["summary"]
        assert "boundary_symbols" in data["summary"]

    def test_safe_zones_by_file(self, cli_runner, indexed_project, monkeypatch):
        """safe-zones accepts a file path as target."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "src/models.py"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_safe_zones_text_output(self, cli_runner, indexed_project, monkeypatch):
        """Text output includes zone classification."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "User"], cwd=indexed_project)
        assert result.exit_code == 0
        output = result.output
        assert "Zone:" in output or "zone" in output.lower()

    def test_safe_zones_unknown_symbol(self, cli_runner, indexed_project, monkeypatch):
        """Unknown symbol shows an error or nonzero exit."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "nonexistent_xyz_99"], cwd=indexed_project)
        # Should either exit nonzero or print an error message
        assert result.exit_code != 0 or "not found" in result.output.lower()

    def test_safe_zones_depth_option(self, cli_runner, indexed_project, monkeypatch):
        """--depth option is accepted."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "User", "--depth", "3"], cwd=indexed_project)
        assert result.exit_code == 0

    def test_safe_zones_affected_files(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes affected_files list."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["safe-zones", "User"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "safe-zones")
        assert "affected_files" in data
        assert isinstance(data["affected_files"], list)


# ============================================================================
# visualize command
# ============================================================================


class TestVisualize:
    """Tests for `roam visualize` -- graph visualization."""

    def test_visualize_runs(self, cli_runner, indexed_project, monkeypatch):
        """visualize exits 0."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["visualize"], cwd=indexed_project)
        assert result.exit_code == 0, f"visualize failed:\n{result.output}"

    def test_visualize_mermaid(self, cli_runner, indexed_project, monkeypatch):
        """Default output is Mermaid format."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["visualize"], cwd=indexed_project)
        assert result.exit_code == 0
        assert "graph TD" in result.output or "graph LR" in result.output

    def test_visualize_json(self, cli_runner, indexed_project, monkeypatch):
        """--json returns a valid envelope."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["visualize"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "visualize")
        assert_json_envelope(data, "visualize")

    def test_visualize_json_has_diagram(self, cli_runner, indexed_project, monkeypatch):
        """JSON envelope includes diagram string."""
        monkeypatch.chdir(indexed_project)
        result = invoke_cli(cli_runner, ["visualize"], cwd=indexed_project, json_mode=True)
        data = parse_json_output(result, "visualize")
        assert "diagram" in data


# ============================================================================
# Multi-file architecture tests using project_factory
# ============================================================================


class TestArchitectureMultiFile:
    """Tests that verify architecture commands on custom project layouts."""

    def test_layers_three_tier(self, project_factory, cli_runner, monkeypatch):
        """A three-tier project should produce at least 2 layers."""
        proj = project_factory(
            {
                "base.py": "class Base:\n    pass\n",
                "mid.py": ("from base import Base\n\nclass Mid(Base):\n    pass\n"),
                "top.py": ("from mid import Mid\n\ndef run():\n    return Mid()\n"),
            }
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["layers"], cwd=proj, json_mode=True)
        data = parse_json_output(result, "layers")
        assert data["summary"]["total_layers"] >= 2

    def test_clusters_separate_groups(self, project_factory, cli_runner, monkeypatch):
        """Two disconnected groups should be detected as separate clusters."""
        proj = project_factory(
            {
                "group_a1.py": ("def func_a1():\n    return 1\n"),
                "group_a2.py": ("from group_a1 import func_a1\n\ndef func_a2():\n    return func_a1() + 1\n"),
                "group_b1.py": ("def func_b1():\n    return 10\n"),
                "group_b2.py": ("from group_b1 import func_b1\n\ndef func_b2():\n    return func_b1() + 10\n"),
            }
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["clusters", "--min-size", "1"], cwd=proj)
        assert result.exit_code == 0

    def test_map_large_project(self, project_factory, cli_runner, monkeypatch):
        """map handles a project with many files."""
        files = {}
        for i in range(10):
            files[f"mod_{i}.py"] = f"def func_{i}():\n    return {i}\n"
        # Add some imports between them
        files["main.py"] = (
            "from mod_0 import func_0\nfrom mod_1 import func_1\n\ndef main():\n    return func_0() + func_1()\n"
        )
        proj = project_factory(files)
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["map"], cwd=proj, json_mode=True)
        data = parse_json_output(result, "map")
        assert data["summary"]["files"] >= 10

    def test_entry_points_main_detected(self, project_factory, cli_runner, monkeypatch):
        """A main() function should be classified as a Main entry point."""
        proj = project_factory(
            {
                "helper.py": ("def compute():\n    return 42\n"),
                "app.py": ("from helper import compute\n\ndef main():\n    print(compute())\n"),
            }
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["entry-points"], cwd=proj, json_mode=True)
        data = parse_json_output(result, "entry-points")
        if data["entry_points"]:
            names = [ep["name"] for ep in data["entry_points"]]
            protocols = [ep["protocol"] for ep in data["entry_points"]]
            # main should appear and be classified as Main
            if "main" in names:
                idx = names.index("main")
                assert protocols[idx] == "Main"

    def test_safe_zones_isolated_function(self, project_factory, cli_runner, monkeypatch):
        """An isolated function with no callers should be ISOLATED or CONTAINED."""
        proj = project_factory(
            {
                "standalone.py": ("def lonely_func():\n    return 'alone'\n"),
            }
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["safe-zones", "lonely_func"], cwd=proj, json_mode=True)
        data = parse_json_output(result, "safe-zones")
        zone = data["summary"]["zone"]
        assert zone in ("ISOLATED", "CONTAINED")

    def test_patterns_strategy_detection(self, project_factory, cli_runner, monkeypatch):
        """Multiple classes inheriting from a base should detect strategy pattern."""
        proj = project_factory(
            {
                "base.py": ("class Processor:\n    def process(self, data):\n        raise NotImplementedError\n"),
                "impl_a.py": (
                    "from base import Processor\n"
                    "\n"
                    "class FastProcessor(Processor):\n"
                    "    def process(self, data):\n"
                    "        return data * 2\n"
                ),
                "impl_b.py": (
                    "from base import Processor\n"
                    "\n"
                    "class SlowProcessor(Processor):\n"
                    "    def process(self, data):\n"
                    "        return data + 1\n"
                ),
            }
        )
        monkeypatch.chdir(proj)
        result = invoke_cli(cli_runner, ["patterns"], cwd=proj, json_mode=True)
        data = parse_json_output(result, "patterns")
        # Strategy pattern requires 2+ inheritors with shared methods
        # Detection depends on edge resolution; verify command runs cleanly
        assert data["summary"]["total_patterns"] >= 0
