import sys
from pathlib import Path

import pytest
from click.testing import CliRunner

from agentmux import __version__
from agentmux.cli.main import cli

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib  # type: ignore[import-not-found]


@pytest.fixture
def runner() -> CliRunner:
    return CliRunner()


@pytest.fixture
def setup_dir(tmp_path: Path) -> Path:
    """A populated agentmux setup directory."""
    (tmp_path / "agents").mkdir()
    (tmp_path / "agents" / "helper.md").write_text(
        "---\nname: Helper\ndescription: A helper\n---\nBe helpful."
    )
    (tmp_path / "commands").mkdir()
    (tmp_path / "commands" / "fix-tests.md").write_text(
        "---\nname: Fix Tests\ndescription: Fix\n---\nFix tests."
    )
    (tmp_path / "skills").mkdir()
    (tmp_path / "rules").mkdir()
    (tmp_path / "instructions.md").write_text("Be concise.")
    (tmp_path / "agentmux.toml").write_text(
        """\
[setup]
name = "test-setup"

[tools]
cursor = { enabled = true, strategy = "symlink" }

[components]
agents = ["agents/*.md"]
commands = ["commands/*.md"]
skills = ["skills/*/SKILL.md"]
rules = ["rules/*.md"]
instructions = "instructions.md"
"""
    )
    return tmp_path


# ── doctor ────────────────────────────────────────────────────────────────────


def test_doctor_runs_and_shows_table(runner: CliRunner) -> None:
    result = runner.invoke(cli, ["doctor"])
    assert result.exit_code == 0
    assert "Tool Detection" in result.output
    assert "cursor" in result.output
    assert "gemini" in result.output


# ── init ──────────────────────────────────────────────────────────────────────


def test_init_creates_structure(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["init"])
        assert result.exit_code == 0
        assert Path("agentmux.toml").exists()
        assert Path("agents").is_dir()
        assert Path("commands").is_dir()
        assert Path("skills").is_dir()
        assert Path("rules").is_dir()
        assert Path("instructions.md").exists()


def test_init_refuses_overwrite_without_force(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text('[setup]\nname = "existing"')
        result = runner.invoke(cli, ["init"])
        assert result.exit_code != 0
        assert "already exists" in result.output


def test_init_force_overwrites(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text('[setup]\nname = "old"')
        result = runner.invoke(cli, ["init", "--force"])
        assert result.exit_code == 0
        content = Path("agentmux.toml").read_text()
        assert "old" not in content


# ── new ───────────────────────────────────────────────────────────────────────

MINIMAL_TOML = """\
[setup]
name = "test-setup"

[tools]
cursor = { enabled = true, strategy = "symlink" }

[components]
agents = ["agents/*.md"]
commands = ["commands/*.md"]
skills = ["skills/*/SKILL.md"]
rules = ["rules/*.md"]
instructions = "instructions.md"
"""


def test_new_agent_creates_file(runner: CliRunner, tmp_path: Path) -> None:
    """agentmux new agent <name> creates agents/<name>.md with valid frontmatter."""
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("agents").mkdir()
        result = runner.invoke(cli, ["new", "agent", "my-agent"])
        assert result.exit_code == 0, result.output
        agent_file = Path("agents/my-agent.md")
        assert agent_file.exists()
        content = agent_file.read_text()
        assert "name: my-agent" in content


def test_new_command_creates_file(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("commands").mkdir()
        result = runner.invoke(cli, ["new", "command", "fix-tests"])
        assert result.exit_code == 0, result.output
        assert Path("commands/fix-tests.md").exists()


def test_new_skill_creates_directory_with_skill_md(runner: CliRunner, tmp_path: Path) -> None:
    """agentmux new skill <name> creates skills/<name>/SKILL.md."""
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("skills").mkdir()
        result = runner.invoke(cli, ["new", "skill", "my-skill"])
        assert result.exit_code == 0, result.output
        skill_md = Path("skills/my-skill/SKILL.md")
        assert skill_md.exists()
        content = skill_md.read_text()
        assert "name: my-skill" in content


def test_new_rule_creates_file(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("rules").mkdir()
        result = runner.invoke(cli, ["new", "rule", "no-console-log"])
        assert result.exit_code == 0, result.output
        assert Path("rules/no-console-log.md").exists()


def test_new_instructions_creates_file(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        result = runner.invoke(cli, ["new", "instructions", "instructions"])
        assert result.exit_code == 0, result.output
        assert Path("instructions.md").exists()


def test_new_mcp_creates_json_file(runner: CliRunner, tmp_path: Path) -> None:
    """agentmux new mcp <name> creates mcp.json with empty mcpServers."""
    import json

    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        result = runner.invoke(cli, ["new", "mcp", "my-mcp"])
        assert result.exit_code == 0, result.output
        mcp_file = Path("mcp.json")
        assert mcp_file.exists()
        data = json.loads(mcp_file.read_text())
        assert "mcpServers" in data
        assert data["mcpServers"] == {}


def test_new_refuses_to_overwrite_existing_file(runner: CliRunner, tmp_path: Path) -> None:
    """Without --force, new must refuse to overwrite an existing file."""
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("agents").mkdir()
        Path("agents/my-agent.md").write_text("existing content")
        result = runner.invoke(cli, ["new", "agent", "my-agent"])
        assert result.exit_code != 0
        assert "already exists" in result.output
        # The original content must be preserved.
        assert Path("agents/my-agent.md").read_text() == "existing content"


def test_new_force_overwrites_existing_file(runner: CliRunner, tmp_path: Path) -> None:
    """With --force, new must overwrite an existing file."""
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("agents").mkdir()
        Path("agents/my-agent.md").write_text("old content")
        result = runner.invoke(cli, ["new", "agent", "my-agent", "--force"])
        assert result.exit_code == 0, result.output
        content = Path("agents/my-agent.md").read_text()
        assert "old content" not in content
        assert "name: my-agent" in content


def test_new_works_without_agentmux_toml(runner: CliRunner, tmp_path: Path) -> None:
    """When no agentmux.toml exists the command falls back to default directories."""
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["new", "agent", "standalone-agent"])
        assert result.exit_code == 0, result.output
        assert Path("agents/standalone-agent.md").exists()


def test_new_rejects_unknown_component_type(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["new", "widget", "my-widget"])
        assert result.exit_code != 0
        assert "Unknown component type" in result.output


def test_new_agent_generates_parseable_file(runner: CliRunner, tmp_path: Path) -> None:
    """The scaffolded agent file must be loadable by Agent.from_file()."""
    from agentmux.core.component import Agent

    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("agents").mkdir()
        runner.invoke(cli, ["new", "agent", "parse-me", "--description", "Parsed"])
        agent = Agent.from_file(Path("agents/parse-me.md"))
        assert agent.name == "parse-me"
        assert agent.description == "Parsed"


def test_new_skill_generates_parseable_skill_dir(runner: CliRunner, tmp_path: Path) -> None:
    """The scaffolded skill directory must be loadable by Skill.from_dir()."""
    from agentmux.core.component import Skill

    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(MINIMAL_TOML)
        Path("skills").mkdir()
        runner.invoke(cli, ["new", "skill", "my-cool-skill"])
        skill = Skill.from_dir(Path("skills/my-cool-skill"))
        assert skill.name == "my-cool-skill"


# ── validate ──────────────────────────────────────────────────────────────────


def test_validate_passes_for_valid_setup(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.chdir(setup_dir)
    result = runner.invoke(cli, ["validate"])
    assert result.exit_code == 0
    assert "valid" in result.output.lower()


def test_validate_fails_when_no_toml(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["validate"])
        assert result.exit_code != 0
        assert "not found" in result.output


def test_validate_ignores_unconfigured_adapters(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """validate must not report errors from adapters not listed in [tools]."""
    rules_dir = tmp_path / "rules"
    rules_dir.mkdir()
    (rules_dir / "my-rule.md").write_text("---\nname: my-rule\nalwaysApply: false\n---\nContent.")
    # Only cursor is enabled; Gemini (which would error on rules) is NOT configured
    (tmp_path / "agentmux.toml").write_text(
        """\
[setup]
name = "test"

[tools]
cursor = { enabled = true, strategy = "symlink" }

[components]
rules = ["rules/*.md"]
"""
    )
    monkeypatch.chdir(tmp_path)
    result = runner.invoke(cli, ["validate"])
    assert result.exit_code == 0
    assert "gemini" not in result.output.lower()


def test_status_only_shows_configured_tools(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """status must only show columns for tools listed in [tools]."""
    monkeypatch.chdir(setup_dir)
    result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    # setup_dir has only cursor configured; gemini column must not appear
    assert "Cursor" in result.output
    assert "Gemini" not in result.output


def test_validate_shows_adapter_errors_for_rules(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Gemini adapter's validate() should surface rule errors in the validate command."""
    rules_dir = tmp_path / "rules"
    rules_dir.mkdir()
    (rules_dir / "my-rule.md").write_text("---\nname: my-rule\nalwaysApply: false\n---\nContent.")
    (tmp_path / "agentmux.toml").write_text(
        """\
[setup]
name = "test"

[tools]
gemini = { enabled = true, strategy = "generate" }

[components]
rules = ["rules/*.md"]
"""
    )
    monkeypatch.chdir(tmp_path)
    result = runner.invoke(cli, ["validate"])
    assert result.exit_code != 0
    assert "gemini" in result.output.lower()


# ── sync ──────────────────────────────────────────────────────────────────────


def test_sync_fails_when_no_toml(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["sync"])
        assert result.exit_code != 0
        assert "not found" in result.output


def test_sync_runs_with_tool_filter(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["sync", "--tool", "cursor"])
    assert result.exit_code == 0
    assert "Sync Results" in result.output


def test_sync_dry_run_shows_dry_run_table_title(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """--dry-run output should include the '(dry run)' label in the table title."""
    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["sync", "--tool", "cursor", "--dry-run"])
    assert result.exit_code == 0
    assert "dry run" in result.output.lower()


def test_sync_dry_run_creates_no_files(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """--dry-run must not write any files to the target directory."""
    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["sync", "--tool", "cursor", "--dry-run"])
    assert result.exit_code == 0

    # Target directory must still be empty after the dry-run
    assert not list(cursor_home.rglob("*")), "dry-run must not write any files"


def test_sync_dry_run_reports_correct_actions(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """--dry-run on a fresh target should report 'created' for new components."""
    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["sync", "--tool", "cursor", "--dry-run"])
    assert result.exit_code == 0
    # At least one 'created' action should be predicted since the target is empty
    assert "created" in result.output


# ── status ────────────────────────────────────────────────────────────────────


def test_status_fails_when_no_toml(runner: CliRunner, tmp_path: Path) -> None:
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["status"])
        assert result.exit_code != 0


def test_status_shows_components(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.chdir(setup_dir)
    result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "Helper" in result.output
    assert "Fix Tests" in result.output


# ── version ───────────────────────────────────────────────────────────────────


def test_version(runner: CliRunner) -> None:
    result = runner.invoke(cli, ["--version"])
    assert result.exit_code == 0
    assert __version__ in result.output


# ── help ──────────────────────────────────────────────────────────────────────


def test_help_shows_all_commands(runner: CliRunner) -> None:
    result = runner.invoke(cli, ["--help"])
    assert "doctor" in result.output
    assert "init" in result.output
    assert "sync" in result.output
    assert "status" in result.output
    assert "validate" in result.output


# ── init TOML round-trip ─────────────────────────────────────────────────────


def test_init_toml_is_parseable(runner: CliRunner, tmp_path: Path) -> None:
    """The generated agentmux.toml must be valid TOML that Setup.from_toml accepts."""
    from agentmux.core.setup import Setup

    with runner.isolated_filesystem(temp_dir=tmp_path) as td:
        result = runner.invoke(cli, ["init"])
        assert result.exit_code == 0
        toml_path = Path("agentmux.toml")
        # Verify it's valid TOML
        with open(toml_path, "rb") as f:
            data = tomllib.load(f)
        assert "setup" in data
        assert data["setup"]["name"] == Path(td).name
        # Verify it round-trips through Setup.from_toml
        setup = Setup.from_toml(toml_path)
        assert setup.name == Path(td).name


# ── status --project ─────────────────────────────────────────────────────────


def test_status_project_shows_project_scoped_components(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """status --project should check project-scoped rules against the project dir."""
    # Create a setup with rules (project-scoped for Cursor)
    setup_dir = tmp_path / "project"
    setup_dir.mkdir()
    (setup_dir / "agents").mkdir()
    (setup_dir / "commands").mkdir()
    (setup_dir / "skills").mkdir()
    (setup_dir / "rules").mkdir()
    (setup_dir / "rules" / "my-rule.md").write_text(
        "---\nname: my-rule\nalwaysApply: true\n---\nFollow this rule."
    )
    (setup_dir / "agentmux.toml").write_text(
        """\
[setup]
name = "test"

[tools]
cursor = { enabled = true, strategy = "generate" }

[components]
rules = ["rules/*.md"]
"""
    )

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    # Without --project: rules should show as needing --project context
    result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "project" in result.output.lower()

    # With --project: rules should show their actual deployment status
    result = runner.invoke(cli, ["status", "--project"])
    assert result.exit_code == 0
    assert "missing" in result.output.lower()


# ── deploy_merge reads through symlinks ───────────────────────────────────────


def test_sync_merge_reads_through_symlink(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """_deploy_merge should read existing content through a symlink before replacing."""
    import json
    import os

    root = tmp_path / "setup"
    root.mkdir()
    (root / "agents").mkdir()
    (root / "agents" / "helper.md").write_text(
        "---\nname: Helper\ndescription: A helper\n---\nBe helpful."
    )
    (root / "agentmux.toml").write_text(
        """\
[setup]
name = "test"

[tools]
gemini = { enabled = true, strategy = "generate" }

[components]
agents = ["agents/*.md"]
"""
    )

    gemini_home = tmp_path / "gemini_home"
    gemini_home.mkdir()

    # Create settings.json as a symlink to a real file with existing data
    real_settings = tmp_path / "real_settings.json"
    real_settings.write_text(json.dumps({"theme": "dark"}))
    settings_link = gemini_home / "settings.json"
    os.symlink(real_settings, settings_link)

    from agentmux.adapters import gemini as gemini_mod
    from agentmux.core.setup import Setup
    from agentmux.core.sync import sync_setup

    monkeypatch.setattr(gemini_mod.GeminiAdapter, "get_global_base_dir", lambda self: gemini_home)

    setup = Setup.from_toml(root / "agentmux.toml")
    sync_setup(setup)

    # The symlink should be replaced with a regular file
    assert not settings_link.is_symlink()
    settings = json.loads(settings_link.read_text())
    # Original content should be preserved via merge
    assert settings.get("theme") == "dark"
    # New content should be added
    assert settings.get("experimental", {}).get("enableAgents") is True


# ── validate: component load failure ─────────────────────────────────────────


def test_validate_fails_when_component_file_is_invalid(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """validate must exit non-zero when a component file cannot be loaded."""
    agents_dir = tmp_path / "agents"
    agents_dir.mkdir()
    # Write a file that is valid YAML frontmatter but whose body causes issues —
    # here we use an MCP file that is not valid JSON to trigger the load error.
    mcp_file = tmp_path / "mcp.json"
    mcp_file.write_text("this is not json")
    (tmp_path / "agentmux.toml").write_text(
        """\
[setup]
name = "test"

[tools]
cursor = { enabled = true, strategy = "symlink" }

[components]
mcps = "mcp.json"
"""
    )
    monkeypatch.chdir(tmp_path)
    result = runner.invoke(cli, ["validate"])
    assert result.exit_code != 0
    assert "Failed to load" in result.output or "error" in result.output.lower()


# ── sync --project CLI flag ───────────────────────────────────────────────────


def test_sync_project_flag_deploys_project_scoped_components(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """sync --project should deploy project-scoped rules into the current directory."""
    setup_dir = tmp_path / "setup"
    setup_dir.mkdir()
    rules_dir = setup_dir / "rules"
    rules_dir.mkdir()
    (rules_dir / "my-rule.md").write_text(
        "---\nname: my-rule\nalwaysApply: true\n---\nFollow this rule."
    )
    (setup_dir / "agentmux.toml").write_text(
        """\
[setup]
name = "test"

[tools]
cursor = { enabled = true, strategy = "generate" }

[components]
rules = ["rules/*.md"]
"""
    )

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    # Without --project: rules should NOT be deployed (project-scoped, no project dir)
    result = runner.invoke(cli, ["sync"])
    assert result.exit_code == 0
    assert not (setup_dir / ".cursor" / "rules").exists()

    # With --project: rules should be deployed into CWD/.cursor/rules/
    result = runner.invoke(cli, ["sync", "--project"])
    assert result.exit_code == 0
    assert (setup_dir / ".cursor" / "rules" / "my-rule.mdc").exists()


# ── init: special characters in directory name ───────────────────────────────


def test_init_escapes_special_characters_in_directory_name(
    runner: CliRunner, tmp_path: Path
) -> None:
    """init must produce valid TOML when the directory name contains special characters."""
    import sys

    if sys.version_info >= (3, 11):
        import tomllib
    else:
        import tomli as tomllib  # type: ignore[import-not-found]

    from agentmux.core.setup import Setup

    # Create a directory with characters that require TOML string escaping
    special_dir = tmp_path / 'my "project"'
    special_dir.mkdir()

    with runner.isolated_filesystem(temp_dir=special_dir):
        result = runner.invoke(cli, ["init"])
        assert result.exit_code == 0, result.output

        toml_path = Path("agentmux.toml")
        assert toml_path.exists()

        # The file must be parseable as valid TOML
        with open(toml_path, "rb") as f:
            data = tomllib.load(f)
        assert "setup" in data

        # And must round-trip through Setup.from_toml without errors
        setup = Setup.from_toml(toml_path)
        assert '"' in setup.name or setup.name  # name was preserved (escaped or not)


# ── sub-phase 2.3: registry update ───────────────────────────────────────────


def test_doctor_shows_opencode_and_claude_code_adapters(runner: CliRunner) -> None:
    """doctor must list all four registered adapters including the new ones."""
    result = runner.invoke(cli, ["doctor"])
    assert result.exit_code == 0
    assert "opencode" in result.output
    assert "claude-code" in result.output


def test_init_generates_symlink_strategy_for_opencode(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """init must assign strategy=symlink when opencode is detected."""
    import agentmux.adapters.opencode as opencode_mod

    monkeypatch.setattr(opencode_mod.OpenCodeAdapter, "detect", lambda self: True)

    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["init"])
        assert result.exit_code == 0
        content = Path("agentmux.toml").read_text()
        assert 'opencode = { enabled = true, strategy = "symlink" }' in content


def test_init_generates_symlink_strategy_for_claude_code(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """init must assign strategy=symlink when claude-code is detected."""
    import agentmux.adapters.claude_code as claude_code_mod

    monkeypatch.setattr(claude_code_mod.ClaudeCodeAdapter, "detect", lambda self: True)

    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["init"])
        assert result.exit_code == 0
        content = Path("agentmux.toml").read_text()
        assert 'claude-code = { enabled = true, strategy = "symlink" }' in content


def test_init_does_not_assign_generate_strategy_for_opencode(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """init must never assign strategy=generate to opencode (it uses markdown files)."""
    import agentmux.adapters.opencode as opencode_mod

    monkeypatch.setattr(opencode_mod.OpenCodeAdapter, "detect", lambda self: True)

    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["init"])
        assert result.exit_code == 0
        content = Path("agentmux.toml").read_text()
        assert 'opencode = { enabled = true, strategy = "generate" }' not in content


def test_adapter_registry_contains_four_adapters() -> None:
    """AdapterRegistry must expose exactly cursor, gemini, opencode, and claude-code."""
    from agentmux.adapters.base import AdapterRegistry

    adapters = AdapterRegistry.get_adapters()
    adapter_names = {a.name for a in adapters}
    assert adapter_names == {"cursor", "gemini", "opencode", "claude-code"}


# ── sub-phase 2.5: install / update / uninstall / diff ───────────────────────

# Minimal agentmux.toml content shared by install/uninstall tests.
_MINIMAL_TOML = """\
[setup]
name = "test-remote-setup"

[tools]
cursor = { enabled = true, strategy = "symlink" }

[components]
agents = ["agents/*.md"]
"""


@pytest.fixture
def remote_setup_dir(tmp_path: Path) -> Path:
    """A self-contained agentmux setup directory that simulates a remote repo."""
    setup = tmp_path / "remote-setup"
    setup.mkdir()
    (setup / "agents").mkdir()
    (setup / "agents" / "helper.md").write_text(
        "---\nname: Helper\ndescription: A helper agent\n---\nBe helpful."
    )
    (setup / "agentmux.toml").write_text(_MINIMAL_TOML)
    return setup


# ── helpers ───────────────────────────────────────────────────────────────────


def _make_registry_entry(
    tmp_path: Path,
    name: str = "my-setup",
    sha: str = "abc123",
    local_path: Path | None = None,
) -> "tuple[Path, Path]":
    """Write a registry.json pre-populated with one entry; return (registry_file, setup_dir)."""
    from agentmux.core.registry import REGISTRY_FILE

    setup_dir = local_path or (tmp_path / "setups" / name)
    setup_dir.mkdir(parents=True, exist_ok=True)

    reg_file = tmp_path / "registry.json"
    import json

    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    name: {
                        "source_url": str(setup_dir),
                        "installed_sha": sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    return reg_file, setup_dir


# ── install ───────────────────────────────────────────────────────────────────


def test_install_local_path_deploys_components(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """install with a local path runs sync and registers the setup."""
    from agentmux.adapters import cursor as cursor_mod
    from agentmux.core.registry import REGISTRY_FILE, Registry

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    # Redirect the registry file so the real ~/.agentmux/registry.json is untouched
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["install", str(remote_setup_dir)])
    assert result.exit_code == 0, result.output
    assert "Installed" in result.output
    assert "remote-setup" in result.output

    # Registry must contain the new entry
    registry = Registry.load(registry_file=reg_file)
    entry = registry.get("remote-setup")
    assert entry is not None
    assert entry.local_path == remote_setup_dir


def test_install_fails_when_no_agentmux_toml(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """install must exit non-zero when the source has no agentmux.toml."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    empty_dir = tmp_path / "empty"
    empty_dir.mkdir()

    result = runner.invoke(cli, ["install", str(empty_dir)])
    assert result.exit_code != 0
    assert "agentmux.toml" in result.output


def test_install_fails_when_source_does_not_exist(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """install must exit non-zero when the local path does not exist."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["install", str(tmp_path / "nonexistent")])
    assert result.exit_code != 0
    assert "does not exist" in result.output


def test_install_fails_when_already_installed(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """install must refuse if a setup with the same name is already registered."""
    from agentmux.adapters import cursor as cursor_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    # First install succeeds
    result = runner.invoke(cli, ["install", str(remote_setup_dir)])
    assert result.exit_code == 0

    # Second install must fail
    result = runner.invoke(cli, ["install", str(remote_setup_dir)])
    assert result.exit_code != 0
    assert "already installed" in result.output


def test_install_custom_name_overrides_derived_name(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """--name flag overrides the name derived from the source path."""
    from agentmux.adapters import cursor as cursor_mod
    from agentmux.core.registry import Registry

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["install", str(remote_setup_dir), "--name", "custom-name"])
    assert result.exit_code == 0, result.output

    registry = Registry.load(registry_file=reg_file)
    assert registry.get("custom-name") is not None
    assert registry.get("remote-setup") is None


# ── update ────────────────────────────────────────────────────────────────────


def test_update_fails_when_no_setups_installed(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update must exit non-zero when the registry is empty."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["update"])
    assert result.exit_code != 0
    assert "No setups are installed" in result.output


def test_update_fails_when_multiple_setups_and_no_name(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update with no name must fail when more than one setup is installed."""
    import json

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "alpha": {
                        "source_url": "/fake/alpha",
                        "installed_sha": "aaa",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(tmp_path / "alpha"),
                    },
                    "beta": {
                        "source_url": "/fake/beta",
                        "installed_sha": "bbb",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(tmp_path / "beta"),
                    },
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["update"])
    assert result.exit_code != 0
    assert "Multiple setups" in result.output


def test_update_fails_for_unknown_setup_name(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update must exit non-zero when the named setup is not in the registry."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["update", "ghost-setup"])
    assert result.exit_code != 0
    assert "ghost-setup" in result.output


def test_update_syncs_and_updates_sha(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update re-runs sync and records the latest SHA in the registry."""
    import json

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.core.registry import Registry
    from agentmux.utils import git as git_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "old_sha_1234567890",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    new_sha = "new_sha_9876543210_abcdef1234"
    # Stub out pull_repo to return a new SHA without actually running git
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: new_sha)

    result = runner.invoke(cli, ["update", "remote-setup"])
    assert result.exit_code == 0, result.output
    assert "Updated" in result.output

    registry = Registry.load(registry_file=reg_file)
    entry = registry.get("remote-setup")
    assert entry is not None
    assert entry.installed_sha == new_sha


# ── uninstall ─────────────────────────────────────────────────────────────────


def test_uninstall_removes_registry_entry(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """uninstall --yes removes the registry entry for the named setup."""
    import json

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.core.registry import Registry

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "abc123",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["uninstall", "remote-setup", "--yes"])
    assert result.exit_code == 0, result.output
    assert "Uninstalled" in result.output

    registry = Registry.load(registry_file=reg_file)
    assert registry.get("remote-setup") is None


def test_uninstall_fails_for_unknown_setup(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """uninstall must exit non-zero when the named setup is not registered."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["uninstall", "ghost-setup", "--yes"])
    assert result.exit_code != 0
    assert "ghost-setup" in result.output


def test_uninstall_removes_deployed_symlinks(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """uninstall --yes removes files/symlinks that were deployed by the setup."""
    import json

    from agentmux.adapters import cursor as cursor_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "abc123",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    # Deploy a file to simulate a previous install.
    # The filename must match what convert_agent produces: sanitize_component_name("Helper") = "Helper"
    agents_dir = cursor_home / "agents"
    agents_dir.mkdir(parents=True)
    deployed_file = agents_dir / "Helper.md"
    deployed_file.write_text("deployed content")

    result = runner.invoke(cli, ["uninstall", "remote-setup", "--yes"])
    assert result.exit_code == 0, result.output
    # The deployed file should have been removed
    assert not deployed_file.exists()


def test_uninstall_cleans_up_cloned_directory(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """uninstall removes the cloned setup directory when it lives inside REGISTRY_DIR."""
    import json

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.core import registry as registry_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    # Place the setup inside a fake REGISTRY_DIR so the cleanup logic fires
    fake_registry_dir = tmp_path / "setups"
    fake_registry_dir.mkdir()
    cloned_setup = fake_registry_dir / "remote-setup"
    cloned_setup.mkdir()
    (cloned_setup / "agentmux.toml").write_text(_MINIMAL_TOML)
    (cloned_setup / "agents").mkdir()
    (cloned_setup / "agents" / "helper.md").write_text(
        "---\nname: Helper\ndescription: A helper agent\n---\nBe helpful."
    )

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": "https://github.com/fake/remote-setup",
                        "installed_sha": "abc123",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(cloned_setup),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(registry_mod, "REGISTRY_DIR", fake_registry_dir)

    result = runner.invoke(cli, ["uninstall", "remote-setup", "--yes"])
    assert result.exit_code == 0, result.output
    # The cloned directory must be deleted
    assert not cloned_setup.exists()


# ── diff ──────────────────────────────────────────────────────────────────────


def test_diff_fails_when_no_setups_installed(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff must exit non-zero when the registry is empty."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["diff"])
    assert result.exit_code != 0
    assert "No setups are installed" in result.output


def test_diff_fails_for_unknown_setup(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff must exit non-zero when the named setup is not registered."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["diff", "ghost-setup"])
    assert result.exit_code != 0
    assert "ghost-setup" in result.output


def test_diff_shows_up_to_date_when_sha_matches(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff shows 'up to date' when current HEAD equals the installed SHA."""
    import json

    from agentmux.utils import git as git_mod

    same_sha = "abc123def456abc123def456abc123def456abcd"

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": same_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "get_head_sha", lambda repo_dir: same_sha)

    result = runner.invoke(cli, ["diff", "remote-setup", "--no-fetch"])
    assert result.exit_code == 0, result.output
    assert "up to date" in result.output.lower()


def test_diff_shows_stat_when_sha_differs(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff shows the git diff --stat output when installed SHA differs from HEAD."""
    import json

    from agentmux.utils import git as git_mod

    old_sha = "old_sha_aaaaaaaaa"
    new_sha = "new_sha_bbbbbbbbb"
    fake_stat = "agents/helper.md | 2 +-\n1 file changed, 1 insertion(+), 1 deletion(-)"

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": old_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "get_head_sha", lambda repo_dir: new_sha)
    monkeypatch.setattr(git_mod, "diff_summary", lambda repo_dir, from_sha, to_sha: fake_stat)

    result = runner.invoke(cli, ["diff", "remote-setup", "--no-fetch"])
    assert result.exit_code == 0, result.output
    assert "agents/helper.md" in result.output


# ── list ──────────────────────────────────────────────────────────────────────


def test_list_shows_installed_setups(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """list must display a table with all installed setups."""
    import json

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "my-setup": {
                        "source_url": "https://github.com/user/my-setup",
                        "installed_sha": "abc123def456",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(tmp_path / "my-setup"),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["list"])
    assert result.exit_code == 0, result.output
    assert "my-setup" in result.output
    assert "https://github" in result.output


def test_list_shows_empty_message_when_no_setups(
    runner: CliRunner,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """list must print a friendly message when the registry is empty."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["list"])
    assert result.exit_code == 0, result.output
    assert "No setups installed" in result.output


# ── help: new commands are listed ─────────────────────────────────────────────


def test_help_shows_new_commands(runner: CliRunner) -> None:
    """--help must list all sub-phase 2.5 commands."""
    result = runner.invoke(cli, ["--help"])
    assert result.exit_code == 0
    assert "install" in result.output
    assert "update" in result.output
    assert "uninstall" in result.output
    assert "diff" in result.output
    assert "list" in result.output


# ── rollback integration ───────────────────────────────────────────────────


def test_sync_saves_rollback_manifest(
    runner: CliRunner,
    setup_dir: Path,
    monkeypatch: pytest.MonkeyPatch,
    tmp_path: Path,
) -> None:
    """agentmux sync should save a rollback manifest after deploying."""
    from agentmux.adapters import cursor as cursor_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    saved: list[object] = []
    monkeypatch.setattr(
        "agentmux.cli.main._save_rollback_manifest",
        lambda actions, name: saved.append((actions, name)),
    )
    monkeypatch.chdir(setup_dir)
    result = runner.invoke(cli, ["sync"])
    # The sync may have nothing to do (already synced) or may create files.
    # Either way it must not crash, and _save_rollback_manifest must be called
    # exactly once.
    assert result.exit_code == 0, result.output
    assert len(saved) == 1


# ── watch mode ────────────────────────────────────────────────────────────


def test_watch_requires_agentmux_toml(runner: CliRunner, tmp_path: Path) -> None:
    """watch must fail gracefully when no agentmux.toml exists."""
    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["watch"])
    assert result.exit_code != 0
    assert "agentmux.toml" in result.output


def test_watch_triggers_sync_on_file_change(
    setup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Watch mode calls sync_setup when a file changes (mocked sync_setup)."""
    import threading
    import time

    from watchdog.observers import Observer as _Observer

    sync_calls: list[int] = []
    captured_observer: list[_Observer] = []

    original_start = _Observer.start

    def _patched_start(self: _Observer) -> None:  # type: ignore[misc]
        captured_observer.append(self)
        original_start(self)  # type: ignore[misc]

    monkeypatch.setattr(_Observer, "start", _patched_start)

    def _mock_sync(setup: object, tools: object = None, project_dir: object = None) -> list:  # type: ignore[misc]
        sync_calls.append(1)
        return []

    # Patch at the source module so the lazy import inside watch() picks it up.
    monkeypatch.setattr("agentmux.core.sync.sync_setup", _mock_sync)
    monkeypatch.chdir(setup_dir)

    from agentmux.cli.main import watch as _watch_cmd

    # Invoke the watch command via Click's standalone_mode=False to control the
    # runner directly without the CliRunner changing the working directory.
    ctx = _watch_cmd.make_context("watch", [], parent=None)

    watcher_thread = threading.Thread(
        target=_watch_cmd.invoke, args=(ctx,), daemon=True
    )
    watcher_thread.start()

    try:
        # Give the observer time to start.
        time.sleep(0.6)

        # Write a new file — the watcher should see it.
        (setup_dir / "agents" / "new-agent.md").write_text("---\nname: new\n---\nHello.")

        # Wait for debounce (300ms) + processing margin.
        time.sleep(0.8)

        assert len(sync_calls) >= 1, "sync_setup was never called — watch did not trigger"
    finally:
        if captured_observer:
            captured_observer[0].stop()
            captured_observer[0].join()
        watcher_thread.join(timeout=2)


def test_watch_debounces_rapid_changes(monkeypatch: pytest.MonkeyPatch) -> None:
    """Multiple rapid file events within the debounce window produce one sync."""
    import threading
    import time

    from agentmux.cli.main import cli as _cli

    sync_calls: list[int] = []

    def _mock_sync(*args: object, **kwargs: object) -> list:  # type: ignore[misc]
        sync_calls.append(1)
        return []

    # Simulate the debounce logic in isolation (without starting a real watcher).
    _debounce_timer: threading.Timer | None = None
    _lock = threading.Lock()

    def _run_sync() -> None:
        nonlocal _debounce_timer
        with _lock:
            _debounce_timer = None
        _mock_sync()

    def _schedule_sync() -> None:
        nonlocal _debounce_timer
        with _lock:
            if _debounce_timer is not None:
                _debounce_timer.cancel()
            _debounce_timer = threading.Timer(0.1, _run_sync)
            _debounce_timer.daemon = True
            _debounce_timer.start()

    # Fire 10 rapid events
    for _ in range(10):
        _schedule_sync()
        time.sleep(0.01)

    # Wait for the single debounced sync to fire.
    time.sleep(0.3)
    assert len(sync_calls) == 1, f"Expected 1 sync call, got {len(sync_calls)}"


# ── verbose / quiet flags ─────────────────────────────────────────────────


def test_verbose_flag_accepted(runner: CliRunner) -> None:
    """--verbose is a valid global flag."""
    result = runner.invoke(cli, ["--verbose", "--help"])
    assert result.exit_code == 0


def test_quiet_flag_accepted(runner: CliRunner) -> None:
    """--quiet is a valid global flag."""
    result = runner.invoke(cli, ["--quiet", "--help"])
    assert result.exit_code == 0


def test_help_shows_rollback_and_watch(runner: CliRunner) -> None:
    """--help must list rollback and watch commands."""
    result = runner.invoke(cli, ["--help"])
    assert result.exit_code == 0
    assert "rollback" in result.output
    assert "watch" in result.output


def test_sync_dry_run_does_not_save_rollback_manifest(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """--dry-run must not write a rollback manifest."""
    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    save_calls: list[int] = []

    def _mock_save(manifest: object) -> Path:
        save_calls.append(1)
        return tmp_path / "unused.json"

    monkeypatch.setattr("agentmux.core.rollback.save_manifest", _mock_save)

    result = runner.invoke(cli, ["sync", "--tool", "cursor", "--dry-run"])
    assert result.exit_code == 0
    assert len(save_calls) == 0, "--dry-run must not persist a rollback manifest"


def test_quiet_flag_suppresses_sync_table(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """--quiet suppresses the sync results table."""
    from agentmux.cli import main as main_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    # Ensure the module-level console.quiet is restored after this test so it
    # does not leak quiet=True into subsequent tests.
    monkeypatch.setattr(main_mod.console, "quiet", main_mod.console.quiet)
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["--quiet", "sync", "--tool", "cursor"])
    assert result.exit_code == 0
    assert "Sync Results" not in result.output


def test_verbose_flag_shows_skipped_rows(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """--verbose makes skipped entries visible in the sync table."""
    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()

    from agentmux.adapters import cursor as cursor_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    # First sync to get files in place, then a second sync will produce 'skipped'.
    runner.invoke(cli, ["sync", "--tool", "cursor"])
    result = runner.invoke(cli, ["--verbose", "sync", "--tool", "cursor"])
    assert result.exit_code == 0
    assert "skipped" in result.output.lower()


def test_watch_raises_when_watchdog_missing(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """watch command raises a friendly error when watchdog is not installed."""
    import builtins

    real_import = builtins.__import__

    def _fake_import(name: str, *args: object, **kwargs: object) -> object:
        if name.startswith("watchdog"):
            raise ImportError("No module named 'watchdog'")
        return real_import(name, *args, **kwargs)  # type: ignore[arg-type]

    monkeypatch.setattr(builtins, "__import__", _fake_import)
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["watch"])
    assert result.exit_code != 0
    assert "watchdog" in result.output.lower()


def test_new_mcp_with_mcps_dict_in_toml(runner: CliRunner, tmp_path: Path) -> None:
    """agentmux new mcp uses the first path in a mcps dict config."""
    toml_content = """\
[setup]
name = "dict-mcp-setup"

[tools]
cursor = { enabled = true, strategy = "symlink" }

[components]
agents = ["agents/*.md"]
mcps = { cursor = "cursor-mcp.json", gemini = "gemini-mcp.json" }
"""
    with runner.isolated_filesystem(temp_dir=tmp_path):
        Path("agentmux.toml").write_text(toml_content)
        result = runner.invoke(cli, ["new", "mcp", "my-servers"])
        assert result.exit_code == 0, result.output
        assert Path("mcp.json").exists()


# ── _deployment_status edge cases ─────────────────────────────────────────────


def test_deployment_status_empty_files(tmp_path: Path) -> None:
    """_deployment_status with no files returns the dim dash placeholder."""
    from agentmux.cli.main import _deployment_status

    result = _deployment_status([], tmp_path)
    assert "–" in result


def test_deployment_status_stale_symlink(tmp_path: Path) -> None:
    """_deployment_status returns 'stale' when a symlink points to a different source."""
    import os

    from agentmux.adapters.base import ConvertedFile
    from agentmux.cli.main import _deployment_status

    source1 = tmp_path / "source1.md"
    source1.write_text("content")
    source2 = tmp_path / "source2.md"
    source2.write_text("other")
    target = tmp_path / "link.md"
    os.symlink(source1, target)

    converted = ConvertedFile(path=Path("link.md"), source=source2)
    result = _deployment_status([converted], tmp_path)

    assert "stale" in result


def test_deployment_status_deployed_regular_file(tmp_path: Path) -> None:
    """_deployment_status returns 'deployed' for a regular (non-symlink) file."""
    from agentmux.adapters.base import ConvertedFile
    from agentmux.cli.main import _deployment_status

    target = tmp_path / "file.md"
    target.write_text("content")

    converted = ConvertedFile(path=Path("file.md"), content="content")
    result = _deployment_status([converted], tmp_path)

    assert "deployed" in result


def test_deployment_status_generated_symlink_no_source(tmp_path: Path) -> None:
    """_deployment_status returns 'deployed' for a generated symlink with no source."""
    import os

    from agentmux.adapters.base import ConvertedFile
    from agentmux.cli.main import _deployment_status

    real = tmp_path / "real.md"
    real.write_text("content")
    target = tmp_path / "link.md"
    os.symlink(real, target)

    converted = ConvertedFile(path=Path("link.md"), source=None)
    result = _deployment_status([converted], tmp_path)

    assert "deployed" in result


def test_deployment_status_exception_returns_error(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """_deployment_status handles exceptions gracefully and returns an error marker."""
    from agentmux.adapters.base import ConvertedFile
    from agentmux.cli.main import _deployment_status

    def _raise(self: object) -> bool:
        raise OSError("permission denied")

    monkeypatch.setattr(Path, "is_symlink", _raise)

    converted = ConvertedFile(path=Path("file.md"), content="content")
    result = _deployment_status([converted], tmp_path)

    assert "error" in result


# ── init: no adapters detected ────────────────────────────────────────────────


def test_init_no_detected_adapters_generates_commented_toml(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """When no adapters are detected, init generates a commented-out [tools] section."""
    import agentmux.adapters.claude_code as cc_mod
    import agentmux.adapters.cursor as cursor_mod
    import agentmux.adapters.gemini as gemini_mod
    import agentmux.adapters.opencode as opencode_mod

    monkeypatch.setattr(cursor_mod.CursorAdapter, "detect", lambda self: False)
    monkeypatch.setattr(gemini_mod.GeminiAdapter, "detect", lambda self: False)
    monkeypatch.setattr(opencode_mod.OpenCodeAdapter, "detect", lambda self: False)
    monkeypatch.setattr(cc_mod.ClaudeCodeAdapter, "detect", lambda self: False)

    with runner.isolated_filesystem(temp_dir=tmp_path):
        result = runner.invoke(cli, ["init"])
        assert result.exit_code == 0
        content = Path("agentmux.toml").read_text()
        assert "# cursor" in content


# ── sync: exit code on errors ─────────────────────────────────────────────────


def test_sync_exits_1_when_actions_contain_errors(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """sync must exit with code 1 when any deployed action has status 'error'."""
    from agentmux.core.sync import SyncAction

    monkeypatch.setattr(
        "agentmux.core.sync.sync_setup",
        lambda *args, **kwargs: [
            SyncAction("error", Path("/some/target"), "agent", "test-agent", "deploy failed")
        ],
    )
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["sync"])
    assert result.exit_code == 1


# ── validate: parse failure ───────────────────────────────────────────────────


def test_validate_exits_1_when_toml_has_syntax_errors(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """validate must exit non-zero and print the error when agentmux.toml is invalid."""
    (tmp_path / "agentmux.toml").write_text("this is not valid toml [[[")
    monkeypatch.chdir(tmp_path)

    result = runner.invoke(cli, ["validate"])

    assert result.exit_code != 0
    assert "agentmux.toml" in result.output


# ── _save_rollback_manifest: OSError ─────────────────────────────────────────


def test_save_rollback_manifest_oserror_prints_warning(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """sync must not crash when saving the rollback manifest raises OSError."""
    from agentmux.adapters import cursor as cursor_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    def _raise(manifest: object) -> None:
        raise OSError("disk full")

    monkeypatch.setattr("agentmux.core.rollback.save_manifest", _raise)
    monkeypatch.chdir(setup_dir)

    result = runner.invoke(cli, ["sync"])
    assert result.exit_code == 0
    assert "could not save rollback manifest" in result.output


# ── sync: second run without --verbose hits 'skipped continue' ───────────────


def test_sync_second_run_without_verbose_omits_skipped_from_table(
    runner: CliRunner, setup_dir: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    """A second sync without --verbose should not show 'skipped' rows in the table."""
    from agentmux.adapters import cursor as cursor_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)
    monkeypatch.chdir(setup_dir)

    runner.invoke(cli, ["sync", "--tool", "cursor"])
    result = runner.invoke(cli, ["sync", "--tool", "cursor"])

    assert result.exit_code == 0
    # Without --verbose, 'skipped' rows are hidden (line 571 executed)
    assert "Sync Results" in result.output


# ── install: load failure and dry-run ────────────────────────────────────────


def test_install_fails_when_toml_content_is_invalid(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """install must exit non-zero when the agentmux.toml has invalid content."""
    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    setup_dir = tmp_path / "bad-setup"
    setup_dir.mkdir()
    (setup_dir / "agentmux.toml").write_text("this is not valid toml [[[")

    result = runner.invoke(cli, ["install", str(setup_dir)])

    assert result.exit_code != 0
    assert "Failed to load" in result.output


def test_install_local_dry_run_previews_without_registering(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """install --dry-run previews the sync without registering or writing files."""
    from agentmux.adapters import cursor as cursor_mod
    from agentmux.core.registry import Registry

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["install", str(remote_setup_dir), "--dry-run"])

    assert result.exit_code == 0, result.output
    assert "Would install" in result.output or "dry run" in result.output.lower()

    registry = Registry.load(registry_file=reg_file)
    assert registry.get("remote-setup") is None


# ── update: additional edge cases ────────────────────────────────────────────


def test_update_auto_resolves_single_installed_setup(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update with no name auto-resolves when exactly one setup is installed."""
    import json

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.utils import git as git_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "old_sha",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: "new_sha_abcdef")

    result = runner.invoke(cli, ["update"])

    assert result.exit_code == 0, result.output
    assert "Updated" in result.output


def test_update_fails_when_setup_directory_is_missing(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """update must exit non-zero when the registered setup directory does not exist."""
    import json

    missing_dir = tmp_path / "missing-setup"
    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "missing-setup": {
                        "source_url": "https://example.com/setup",
                        "installed_sha": "abc",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(missing_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["update", "missing-setup"])

    assert result.exit_code != 0
    assert "does not exist" in result.output


def test_update_fails_when_pull_raises_runtime_error(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update must exit non-zero when git pull fails."""
    import json

    from agentmux.utils import git as git_mod

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "old",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    def _fail(repo_dir: Path) -> str:
        raise RuntimeError("merge conflict")

    monkeypatch.setattr(git_mod, "pull_repo", _fail)

    result = runner.invoke(cli, ["update", "remote-setup"])

    assert result.exit_code != 0
    assert "Failed to pull" in result.output or "merge conflict" in result.output


def test_update_shows_up_to_date_when_sha_unchanged(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update prints 'Already up to date' when the SHA doesn't change after pull."""
    import json

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.utils import git as git_mod

    same_sha = "same_sha_12345"
    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": same_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: same_sha)

    result = runner.invoke(cli, ["update", "remote-setup"])

    assert result.exit_code == 0, result.output
    assert "up to date" in result.output.lower()


def test_update_fails_when_toml_missing_after_pull(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """update must exit non-zero when agentmux.toml is missing from the setup dir."""
    import json

    from agentmux.utils import git as git_mod

    setup_dir = tmp_path / "setup"
    setup_dir.mkdir()
    # No agentmux.toml in setup_dir

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "my-setup": {
                        "source_url": "https://example.com",
                        "installed_sha": "old",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: "new_sha")

    result = runner.invoke(cli, ["update", "my-setup"])

    assert result.exit_code != 0
    assert "agentmux.toml" in result.output


def test_update_fails_when_toml_is_invalid_after_pull(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """update must exit non-zero when agentmux.toml cannot be parsed after pull."""
    import json

    from agentmux.utils import git as git_mod

    setup_dir = tmp_path / "setup"
    setup_dir.mkdir()
    (setup_dir / "agentmux.toml").write_text("not valid toml [[[")

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "my-setup": {
                        "source_url": "https://example.com",
                        "installed_sha": "old",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: "new_sha")

    result = runner.invoke(cli, ["update", "my-setup"])

    assert result.exit_code != 0
    assert "Failed to load" in result.output


def test_update_dry_run_previews_without_updating_registry(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update --dry-run previews the re-deploy without updating the registry SHA."""
    import json

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.core.registry import Registry
    from agentmux.utils import git as git_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    old_sha = "old_sha_before_pull"
    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": old_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: "new_sha_after_pull")

    result = runner.invoke(cli, ["update", "remote-setup", "--dry-run"])

    assert result.exit_code == 0, result.output
    assert "dry run" in result.output.lower() or "Would update" in result.output

    registry = Registry.load(registry_file=reg_file)
    entry = registry.get("remote-setup")
    assert entry is not None
    assert entry.installed_sha == old_sha


def test_update_exits_1_when_sync_produces_errors(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update must exit with code 1 when any sync action has status 'error'."""
    import json

    from agentmux.core.sync import SyncAction
    from agentmux.utils import git as git_mod

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "old",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: "new_sha")
    monkeypatch.setattr(
        "agentmux.core.sync.sync_setup",
        lambda *args, **kwargs: [
            SyncAction("error", Path("/target"), "agent", "test", "deploy failed")
        ],
    )

    result = runner.invoke(cli, ["update", "remote-setup"])

    assert result.exit_code == 1


# ── diff: additional edge cases ───────────────────────────────────────────────


def test_diff_fails_for_multiple_setups_without_name(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """diff with no name must fail when multiple setups are installed."""
    import json

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "setup-a": {
                        "source_url": "/fake/a",
                        "installed_sha": "aaa",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(tmp_path / "a"),
                    },
                    "setup-b": {
                        "source_url": "/fake/b",
                        "installed_sha": "bbb",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(tmp_path / "b"),
                    },
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["diff"])

    assert result.exit_code != 0
    assert "Multiple setups" in result.output


def test_diff_auto_resolves_single_setup(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff with no name auto-resolves when exactly one setup is installed."""
    import json

    from agentmux.utils import git as git_mod

    same_sha = "abc123def456abc123def456abc123def456abcd"
    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": same_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "get_head_sha", lambda repo_dir: same_sha)

    result = runner.invoke(cli, ["diff", "--no-fetch"])

    assert result.exit_code == 0, result.output
    assert "remote-setup" in result.output


def test_diff_fails_when_setup_directory_is_missing(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """diff must exit non-zero when the setup directory does not exist."""
    import json

    missing = tmp_path / "nonexistent-setup"
    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "ghost-setup": {
                        "source_url": "https://example.com",
                        "installed_sha": "abc",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(missing),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["diff", "ghost-setup"])

    assert result.exit_code != 0
    assert "does not exist" in result.output


def test_diff_fails_when_head_sha_unavailable(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff must exit non-zero when git HEAD cannot be determined."""
    import json

    from agentmux.utils import git as git_mod

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "abc",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    def _fail(repo_dir: Path) -> str:
        raise RuntimeError("not a git repository")

    monkeypatch.setattr(git_mod, "get_head_sha", _fail)

    result = runner.invoke(cli, ["diff", "remote-setup", "--no-fetch"])

    assert result.exit_code != 0
    assert "HEAD" in result.output or "git repository" in result.output


def test_diff_shows_no_file_changes_when_diff_is_empty(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff prints 'No file changes' when diff_summary returns empty string."""
    import json

    from agentmux.utils import git as git_mod

    old_sha = "old_sha_aaaa"
    new_sha = "new_sha_bbbb"
    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": old_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "get_head_sha", lambda repo_dir: new_sha)
    monkeypatch.setattr(git_mod, "diff_summary", lambda repo_dir, f, t: "")

    result = runner.invoke(cli, ["diff", "remote-setup", "--no-fetch"])

    assert result.exit_code == 0, result.output
    assert "No file changes" in result.output


def test_diff_shows_sha_unavailable_when_both_shas_missing(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff shows 'SHA information unavailable' when neither installed nor current SHA is known."""
    import json

    from agentmux.utils import git as git_mod

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": "previously-known-sha",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "get_head_sha", lambda repo_dir: "")

    result = runner.invoke(cli, ["diff", "remote-setup", "--no-fetch"])

    assert result.exit_code == 0, result.output
    assert "unavailable" in result.output.lower()


def test_diff_shows_remote_ahead_when_remote_sha_differs(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff shows a 'Remote is ahead' message when remote tracking SHA differs from HEAD."""
    import json

    from agentmux.utils import git as git_mod

    current_sha = "current_sha_local"
    remote_sha = "remote_sha_ahead"

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": current_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "get_head_sha", lambda repo_dir: current_sha)
    monkeypatch.setattr(git_mod, "get_remote_tracking_sha", lambda repo_dir: remote_sha)
    monkeypatch.setattr(git_mod, "fetch_repo", lambda repo_dir: None)

    result = runner.invoke(cli, ["diff", "remote-setup"])

    assert result.exit_code == 0, result.output
    assert "Remote is ahead" in result.output


def test_diff_handles_fetch_failure_gracefully(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """diff prints a warning but continues when git fetch fails."""
    import json

    from agentmux.utils import git as git_mod

    same_sha = "same_sha_12345"
    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": same_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "get_head_sha", lambda repo_dir: same_sha)
    monkeypatch.setattr(git_mod, "get_remote_tracking_sha", lambda repo_dir: None)

    def _fail_fetch(repo_dir: Path) -> None:
        raise RuntimeError("no network")

    monkeypatch.setattr(git_mod, "fetch_repo", _fail_fetch)

    result = runner.invoke(cli, ["diff", "remote-setup"])

    assert result.exit_code == 0, result.output
    assert "fetch failed" in result.output or "Warning" in result.output


# ── rollback: additional edge cases ──────────────────────────────────────────


def test_rollback_fails_for_multiple_setups_without_name(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """rollback with no name fails when multiple setups have rollback history."""
    import json
    from datetime import datetime, timezone

    from agentmux.core.rollback import RollbackManifest, save_manifest

    rollback_dir = tmp_path / "rollback"
    monkeypatch.setattr("agentmux.core.rollback._ROLLBACK_DIR", rollback_dir)

    for name in ("setup-alpha", "setup-beta"):
        manifest = RollbackManifest(
            timestamp=datetime.now(tz=timezone.utc),
            setup_name=name,
            entries=[],
        )
        save_manifest(manifest)

    result = runner.invoke(cli, ["rollback"])

    assert result.exit_code != 0
    assert "Multiple setups" in result.output


def test_rollback_fails_when_manifest_cannot_be_loaded(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """rollback must fail gracefully when load_latest_manifest returns None."""
    from agentmux.core import rollback as rollback_mod

    rollback_dir = tmp_path / "rollback"
    monkeypatch.setattr("agentmux.core.rollback._ROLLBACK_DIR", rollback_dir)

    monkeypatch.setattr(rollback_mod, "list_manifests", lambda name: [Path("/fake/manifest.json")])
    monkeypatch.setattr(rollback_mod, "load_latest_manifest", lambda name: None)

    result = runner.invoke(cli, ["rollback", "test-setup"])

    assert result.exit_code != 0
    assert "Could not load rollback manifest" in result.output


def test_rollback_prints_nothing_to_do_when_entries_empty(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """rollback with an empty manifest prints 'Nothing to roll back'."""
    from datetime import datetime, timezone

    from agentmux.core.rollback import RollbackManifest, save_manifest

    rollback_dir = tmp_path / "rollback"
    monkeypatch.setattr("agentmux.core.rollback._ROLLBACK_DIR", rollback_dir)

    manifest = RollbackManifest(
        timestamp=datetime.now(tz=timezone.utc),
        setup_name="test-setup",
        entries=[],
    )
    save_manifest(manifest)

    result = runner.invoke(cli, ["rollback", "test-setup"])

    assert result.exit_code == 0, result.output
    assert "Nothing to roll back" in result.output


def test_rollback_deletes_directory_targets(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """rollback removes directories that were created during sync."""
    from datetime import datetime, timezone

    from agentmux.core.rollback import RollbackEntry, RollbackManifest, save_manifest

    rollback_dir = tmp_path / "rollback"
    monkeypatch.setattr("agentmux.core.rollback._ROLLBACK_DIR", rollback_dir)

    target_dir = tmp_path / "deployed_skill"
    target_dir.mkdir()
    (target_dir / "SKILL.md").write_text("skill content")

    entry = RollbackEntry(
        target_path=str(target_dir),
        backup_path=None,
        action="created",
    )
    manifest = RollbackManifest(
        timestamp=datetime.now(tz=timezone.utc),
        setup_name="test-setup",
        entries=[entry],
    )
    save_manifest(manifest)

    result = runner.invoke(cli, ["rollback", "test-setup", "--yes"])

    assert result.exit_code == 0, result.output
    assert not target_dir.exists()


def test_rollback_handles_oserror_during_delete(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """rollback exits 1 when a file deletion raises OSError."""
    from datetime import datetime, timezone

    from agentmux.core.rollback import RollbackEntry, RollbackManifest, save_manifest

    rollback_dir = tmp_path / "rollback"
    monkeypatch.setattr("agentmux.core.rollback._ROLLBACK_DIR", rollback_dir)

    target = tmp_path / "deployed.md"
    target.write_text("content")

    entry = RollbackEntry(
        target_path=str(target),
        backup_path=None,
        action="created",
    )
    manifest = RollbackManifest(
        timestamp=datetime.now(tz=timezone.utc),
        setup_name="test-setup",
        entries=[entry],
    )
    save_manifest(manifest)

    original_unlink = Path.unlink

    def _raise_on_target(self: Path, missing_ok: bool = False) -> None:
        if self == target:
            raise OSError("permission denied")
        original_unlink(self, missing_ok=missing_ok)

    monkeypatch.setattr(Path, "unlink", _raise_on_target)

    result = runner.invoke(cli, ["rollback", "test-setup", "--yes"])

    assert result.exit_code == 1
    assert "Could not delete" in result.output


def test_rollback_skips_entry_with_missing_backup_path(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """rollback skips 'updated' entries that have no backup_path."""
    from datetime import datetime, timezone

    from agentmux.core.rollback import RollbackEntry, RollbackManifest, save_manifest

    rollback_dir = tmp_path / "rollback"
    monkeypatch.setattr("agentmux.core.rollback._ROLLBACK_DIR", rollback_dir)

    target = tmp_path / "deployed.md"
    target.write_text("content")

    entry = RollbackEntry(
        target_path=str(target),
        backup_path=None,
        action="updated",
    )
    manifest = RollbackManifest(
        timestamp=datetime.now(tz=timezone.utc),
        setup_name="test-setup",
        entries=[entry],
    )
    save_manifest(manifest)

    result = runner.invoke(cli, ["rollback", "test-setup", "--yes"])

    assert result.exit_code == 0, result.output
    assert "No backup path recorded" in result.output


# ── _deployment_status: linked symlink ───────────────────────────────────────


def test_deployment_status_correctly_linked_symlink(tmp_path: Path) -> None:
    """_deployment_status returns 'linked' when a symlink points to the correct source."""
    import os

    from agentmux.adapters.base import ConvertedFile
    from agentmux.cli.main import _deployment_status

    source = tmp_path / "source.md"
    source.write_text("content")
    target = tmp_path / "link.md"
    os.symlink(source, target)

    converted = ConvertedFile(path=Path("link.md"), source=source)
    result = _deployment_status([converted], tmp_path)

    assert "linked" in result


# ── install: git URL that fails to clone ─────────────────────────────────────


def test_install_git_url_fails_to_clone(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """install must exit non-zero and show error when git clone fails."""
    from agentmux.utils import git as git_mod

    reg_file = tmp_path / "registry.json"
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(
        "agentmux.core.registry.REGISTRY_DIR", tmp_path / "registry_dir"
    )

    def _fail_clone(url: str, dest: Path) -> None:
        raise RuntimeError("connection refused")

    monkeypatch.setattr(git_mod, "clone_repo", _fail_clone)

    result = runner.invoke(cli, ["install", "https://github.com/fake/setup"])

    assert result.exit_code != 0
    assert "clone" in result.output.lower() or "connection refused" in result.output


# ── uninstall: graceful degradation when toml is broken ──────────────────────


def test_uninstall_with_broken_toml_gracefully_degrades(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """uninstall --yes prints a warning but still removes the registry entry
    when agentmux.toml is unreadable."""
    import json

    from agentmux.core.registry import Registry

    reg_file = tmp_path / "registry.json"
    setup_dir = tmp_path / "broken-setup"
    setup_dir.mkdir()
    (setup_dir / "agentmux.toml").write_text("not valid toml [[[")

    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "broken-setup": {
                        "source_url": "https://example.com/broken",
                        "installed_sha": "abc123",
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)

    result = runner.invoke(cli, ["uninstall", "broken-setup", "--yes"])

    assert result.exit_code == 0, result.output
    assert "Warning" in result.output or "could not load" in result.output.lower()
    registry = Registry.load(registry_file=reg_file)
    assert registry.get("broken-setup") is None


# ── rollback: auto-resolves single setup name ─────────────────────────────────


def test_rollback_auto_resolves_single_setup_name(
    runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """rollback with no name auto-resolves when exactly one setup has rollback history."""
    from datetime import datetime, timezone

    from agentmux.core.rollback import RollbackManifest, save_manifest

    rollback_dir = tmp_path / "rollback"
    monkeypatch.setattr("agentmux.core.rollback._ROLLBACK_DIR", rollback_dir)

    manifest = RollbackManifest(
        timestamp=datetime.now(tz=timezone.utc),
        setup_name="sole-setup",
        entries=[],
    )
    save_manifest(manifest)

    result = runner.invoke(cli, ["rollback"])

    assert result.exit_code == 0, result.output
    assert "Nothing to roll back" in result.output or "sole-setup" in result.output


# ── update: diff shown when SHA changes ──────────────────────────────────────


def test_update_shows_diff_summary_when_sha_changes(
    runner: CliRunner,
    remote_setup_dir: Path,
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """update prints a diff summary when the SHA changes after pull."""
    import json

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.utils import git as git_mod

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    monkeypatch.setattr(cursor_mod.CursorAdapter, "get_global_base_dir", lambda self: cursor_home)

    old_sha = "old_sha_111111"
    new_sha = "new_sha_222222"
    fake_stat = "agents/helper.md | 1 +\n1 file changed"

    reg_file = tmp_path / "registry.json"
    reg_file.write_text(
        json.dumps(
            {
                "setups": {
                    "remote-setup": {
                        "source_url": str(remote_setup_dir),
                        "installed_sha": old_sha,
                        "installed_at": "2026-02-23T10:00:00Z",
                        "local_path": str(remote_setup_dir),
                    }
                }
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr("agentmux.core.registry.REGISTRY_FILE", reg_file)
    monkeypatch.setattr(git_mod, "pull_repo", lambda repo_dir: new_sha)
    monkeypatch.setattr(git_mod, "diff_summary", lambda repo_dir, f, t: fake_stat)

    result = runner.invoke(cli, ["update", "remote-setup"])

    assert result.exit_code == 0, result.output
    assert "agents/helper.md" in result.output
