import json
import os
from pathlib import Path
from typing import Any

import pytest

from agentmux.adapters.base import ConvertedFile
from agentmux.core.setup import InstallStrategy, Setup
from agentmux.core.sync import (
    _deep_merge,
    _deploy,
    _deploy_copy,
    _deploy_merge,
    _predict_action,
    sync_setup,
)
from tests.conftest import populate_setup

TOML_CURSOR_ONLY = """\
[setup]
name = "test"

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

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

TOML_GEMINI_ONLY = """\
[setup]
name = "test"

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

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


# ── _deep_merge ───────────────────────────────────────────────────────────────


def test_deep_merge_flat() -> None:
    result = _deep_merge({"a": 1}, {"b": 2})
    assert result == {"a": 1, "b": 2}


def test_deep_merge_nested() -> None:
    base = {"outer": {"a": 1, "b": 2}}
    update = {"outer": {"b": 99, "c": 3}}
    result = _deep_merge(base, update)
    assert result == {"outer": {"a": 1, "b": 99, "c": 3}}


def test_deep_merge_does_not_mutate() -> None:
    base = {"a": 1}
    _deep_merge(base, {"b": 2})
    assert base == {"a": 1}


# ── Cursor sync ───────────────────────────────────────────────────────────────


def test_sync_cursor_creates_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

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

    # Patch CursorAdapter to use tmp dir as global base
    from agentmux.adapters import cursor as cursor_mod

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

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

    agent_file = cursor_home / "agents" / "Helper.md"
    assert agent_file.exists()
    content = agent_file.read_text()
    assert "name: Helper" in content
    assert "Be helpful." in content


def test_sync_cursor_creates_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """Commands are always written as content (no frontmatter), regardless of strategy."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

    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)

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

    cmd_file = cursor_home / "commands" / "fix-tests.md"
    assert cmd_file.exists()
    content = cmd_file.read_text()
    assert "Fix all failing tests." in content
    assert "---" not in content  # frontmatter must be stripped in all strategies


def test_sync_cursor_generate_command_strips_frontmatter(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """strategy=generate writes body-only content (no frontmatter) for Cursor commands."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)

    toml_generate = """\
[setup]
name = "test"

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

[components]
agents = ["agents/*.md"]
commands = ["commands/*.md"]
skills = ["skills/*/SKILL.md"]
rules = ["rules/*.md"]
instructions = "instructions.md"
"""
    (root / "agentmux.toml").write_text(toml_generate)

    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)

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

    cmd_file = cursor_home / "commands" / "fix-tests.md"
    assert cmd_file.exists()
    content = cmd_file.read_text()
    assert "---" not in content
    assert "Fix all failing tests." in content


def test_sync_cursor_symlinks_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

    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)

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

    skill_link = cursor_home / "skills" / "my-skill"
    assert skill_link.is_symlink() or skill_link.is_dir()


def test_sync_cursor_rules_only_with_project(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

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

    from agentmux.adapters import cursor as cursor_mod

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

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

    # Without --project: no rules deployed
    sync_setup(setup)
    assert not (cursor_home / "rules").exists()

    # With --project: rules deployed to project_dir/.cursor/rules/
    sync_setup(setup, project_dir=project_dir)
    rules_dir = project_dir / ".cursor" / "rules"
    assert (rules_dir / "no-comments.mdc").exists()
    assert (rules_dir / "instructions.mdc").exists()


# ── Gemini sync ───────────────────────────────────────────────────────────────


def test_sync_gemini_creates_agent_with_kind_local(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_GEMINI_ONLY)

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

    from agentmux.adapters import gemini as gemini_mod

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

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

    agent_file = gemini_home / "agents" / "Helper.md"
    assert agent_file.exists()
    assert "kind: local" in agent_file.read_text()


def test_sync_gemini_creates_toml_command(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_GEMINI_ONLY)

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

    from agentmux.adapters import gemini as gemini_mod

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

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

    cmd_file = gemini_home / "commands" / "fix-tests.toml"
    assert cmd_file.exists()
    assert "prompt = '''" in cmd_file.read_text()


def test_sync_gemini_updates_settings_for_agents(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_GEMINI_ONLY)

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

    from agentmux.adapters import gemini as gemini_mod

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

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

    settings_file = gemini_home / "settings.json"
    assert settings_file.exists()
    settings = json.loads(settings_file.read_text())
    assert settings.get("experimental", {}).get("enableAgents") is True


def test_sync_gemini_preserves_existing_settings(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_GEMINI_ONLY)

    gemini_home = tmp_path / "gemini_home"
    gemini_home.mkdir()
    # Pre-existing settings
    (gemini_home / "settings.json").write_text(
        json.dumps({"theme": "dark", "other": {"key": "value"}})
    )

    from agentmux.adapters import gemini as gemini_mod

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

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

    settings = json.loads((gemini_home / "settings.json").read_text())
    assert settings.get("theme") == "dark"
    assert settings.get("other", {}).get("key") == "value"
    assert settings.get("experimental", {}).get("enableAgents") is True


def test_sync_gemini_deploys_instructions_globally(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Gemini instructions have scope=global and deploy to global base without --project."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_GEMINI_ONLY)

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

    from agentmux.adapters import gemini as gemini_mod

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

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

    assert (gemini_home / "GEMINI.md").exists()
    assert "Be concise and helpful." in (gemini_home / "GEMINI.md").read_text()


# ── Tool filter ───────────────────────────────────────────────────────────────


def test_sync_tool_filter(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)

    combined = """\
[setup]
name = "test"

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

[components]
agents = ["agents/*.md"]
commands = ["commands/*.md"]
"""
    (root / "agentmux.toml").write_text(combined)

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

    from agentmux.adapters import cursor as cursor_mod
    from agentmux.adapters import gemini as gemini_mod

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

    setup = Setup.from_toml(root / "agentmux.toml")
    sync_setup(setup, tools=["cursor"])

    assert (cursor_home / "agents" / "Helper.md").exists()
    assert not (gemini_home / "agents").exists()


# ── Skipped actions ───────────────────────────────────────────────────────────


def test_sync_returns_skipped_when_already_synced(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

    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)

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

    actions = sync_setup(setup)  # second run
    statuses = {a.action for a in actions}
    assert "created" not in statuses  # all should be skipped or updated


# ── Strategy ──────────────────────────────────────────────────────────────────

TOML_CURSOR_SYMLINK = """\
[setup]
name = "test"

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

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

TOML_CURSOR_GENERATE = """\
[setup]
name = "test"

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

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


def test_sync_strategy_generate_writes_agent_content(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    (root / "agents").mkdir()
    agent_file = root / "agents" / "helper.md"
    agent_file.write_text("---\nname: Helper\ndescription: A helper\n---\nBe helpful.")
    (root / "agentmux.toml").write_text(TOML_CURSOR_GENERATE)

    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)

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

    deployed = cursor_home / "agents" / "Helper.md"
    assert deployed.exists()
    assert not deployed.is_symlink()


def test_sync_strategy_symlink_writes_agent_content(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """symlink strategy for agents writes generated content (adapters always transform agents)."""
    root = tmp_path / "setup"
    root.mkdir()
    (root / "agents").mkdir()
    agent_file = root / "agents" / "helper.md"
    agent_file.write_text("---\nname: Helper\ndescription: A helper\n---\nBe helpful.")
    (root / "agentmux.toml").write_text(TOML_CURSOR_SYMLINK)

    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)

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

    deployed = cursor_home / "agents" / "Helper.md"
    assert deployed.exists()
    # Agents are always transformed, so no symlink even under symlink strategy
    assert not deployed.is_symlink()
    assert "Be helpful." in deployed.read_text()


def test_sync_strategy_copy_returns_skipped_on_second_run(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """copy strategy must return 'skipped' when the file already exists and content is identical."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)

    toml_copy = """\
[setup]
name = "test"

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

[components]
agents = ["agents/*.md"]
"""
    (root / "agentmux.toml").write_text(toml_copy)

    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)

    setup = Setup.from_toml(root / "agentmux.toml")
    actions_first = sync_setup(setup)
    assert any(a.action == "created" for a in actions_first)

    actions_second = sync_setup(setup)
    # On the second run the unchanged file should be skipped, not created again
    agent_actions = [a for a in actions_second if "Helper" in a.component_name]
    assert any(a.action == "skipped" for a in agent_actions)
    assert not any(a.action == "created" for a in agent_actions)


def test_sync_strategy_copy_returns_updated_when_source_changes(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """copy strategy must return 'updated' when the source file changes between runs."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)

    toml_copy = """\
[setup]
name = "test"

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

[components]
skills = ["skills/*/SKILL.md"]
"""
    (root / "agentmux.toml").write_text(toml_copy)

    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)

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

    # Modify source skill file to simulate a change
    skill_md = root / "skills" / "my-skill" / "SKILL.md"
    skill_md.write_text(skill_md.read_text() + "\nExtra content.")

    actions_second = sync_setup(setup)
    skill_actions = [a for a in actions_second if "My Skill" in a.component_name]
    assert any(a.action == "updated" for a in skill_actions)


# ── COPY strategy ─────────────────────────────────────────────────────────────


def test_sync_strategy_copy_copies_skill_directory(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """strategy=copy copies skill directories instead of symlinking them."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)

    toml_copy = """\
[setup]
name = "test"

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

[components]
skills = ["skills/*/SKILL.md"]
"""
    (root / "agentmux.toml").write_text(toml_copy)

    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)

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

    skill_target = cursor_home / "skills" / "my-skill"
    assert skill_target.exists()
    assert not skill_target.is_symlink()  # must be a copy, not a symlink
    assert (skill_target / "SKILL.md").exists()


# ── synced_types ───────────────────────────────────────────────────────────────


def test_sync_synced_types_includes_command(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """synced_types passed to post_sync must include COMMAND when commands are deployed."""
    from agentmux.core.component import ComponentType

    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_GEMINI_ONLY)

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

    captured: dict[str, set[ComponentType]] = {}

    from agentmux.adapters import gemini as gemini_mod

    original_post_sync = gemini_mod.GeminiAdapter.post_sync

    def mock_post_sync(
        self: Any, base_dir: Path, synced_types: set[ComponentType]
    ) -> list[ConvertedFile]:
        captured["synced_types"] = set(synced_types)
        return original_post_sync(self, base_dir, synced_types)

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

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

    assert ComponentType.COMMAND in captured["synced_types"]
    assert ComponentType.AGENT in captured["synced_types"]


# ── post_sync component type ───────────────────────────────────────────────────


def test_sync_post_sync_uses_settings_label(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_GEMINI_ONLY)

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

    from agentmux.adapters import gemini as gemini_mod

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

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

    settings_actions = [a for a in actions if a.component_type == "settings"]
    assert len(settings_actions) >= 1
    assert all(a.component_name == "settings" for a in settings_actions)


# ── Dry-run ────────────────────────────────────────────────────────────────────


def test_sync_dry_run_returns_created_without_writing_files(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Dry-run on a fresh setup predicts 'created' without writing anything."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

    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)

    setup = Setup.from_toml(root / "agentmux.toml")
    actions = sync_setup(setup, dry_run=True)

    # Should predict at least one 'created' action (the agent file doesn't exist yet)
    assert any(a.action == "created" for a in actions)

    # No files should have been written
    assert not list(cursor_home.rglob("*"))


def test_sync_dry_run_returns_skipped_when_already_synced(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Dry-run after a real sync predicts 'skipped' because files already match."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

    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)

    setup = Setup.from_toml(root / "agentmux.toml")
    sync_setup(setup)  # real sync — creates files

    actions = sync_setup(setup, dry_run=True)  # dry-run on already-synced state
    action_values = {a.action for a in actions}
    assert "created" not in action_values, (
        "Dry-run after full sync should not predict 'created' — files already exist"
    )


def test_sync_dry_run_no_filesystem_side_effects(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Dry-run must not create, modify, or delete any files."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

    cursor_home = tmp_path / "cursor_home"
    cursor_home.mkdir()
    # Plant a sentinel file to detect if anything is modified
    sentinel = cursor_home / "_sentinel.txt"
    sentinel.write_text("untouched")

    from agentmux.adapters import cursor as cursor_mod

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

    files_before = set(cursor_home.rglob("*"))

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

    files_after = set(cursor_home.rglob("*"))
    assert files_before == files_after, (
        "Dry-run must not create or remove any files in the target directory"
    )
    assert sentinel.read_text() == "untouched"


def test_sync_dry_run_predicts_updated_when_content_changes(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Dry-run after a source file is changed predicts 'updated' for that component."""
    root = tmp_path / "setup"
    root.mkdir()
    populate_setup(root)
    (root / "agentmux.toml").write_text(TOML_CURSOR_ONLY)

    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)

    setup = Setup.from_toml(root / "agentmux.toml")
    sync_setup(setup)  # real sync — creates files

    # Modify the deployed agent file so it no longer matches the source
    deployed_agent = cursor_home / "agents" / "Helper.md"
    deployed_agent.write_text("old content that no longer matches")

    actions = sync_setup(setup, dry_run=True)
    agent_action = next((a for a in actions if a.component_name == "Helper"), None)
    assert agent_action is not None
    assert agent_action.action == "updated"

    # Deployed file must NOT have been modified by the dry-run
    assert deployed_agent.read_text() == "old content that no longer matches"


# ── _deploy_merge edge cases ──────────────────────────────────────────────────


def test_deploy_merge_raises_on_invalid_json(tmp_path: Path) -> None:
    """_deploy_merge must raise ValueError when the existing file contains invalid JSON."""
    target = tmp_path / "config.json"
    target.write_text("not valid json at all")
    converted = ConvertedFile(path=Path("config.json"), content='{"a": 1}', merge=True)
    with pytest.raises(ValueError, match="not valid JSON"):
        _deploy_merge(converted, target)


# ── _deploy_copy edge cases ───────────────────────────────────────────────────


def test_deploy_copy_raises_when_source_is_none(tmp_path: Path) -> None:
    """_deploy_copy must raise ValueError when ConvertedFile.source is None."""
    converted = ConvertedFile(path=Path("file.txt"), source=None)
    with pytest.raises(ValueError, match="source=None"):
        _deploy_copy(converted, tmp_path / "file.txt")


def test_deploy_copy_replaces_symlink_target(tmp_path: Path) -> None:
    """_deploy_copy must unlink a symlink at the target and copy the source."""
    source = tmp_path / "source.txt"
    source.write_text("source content")
    real = tmp_path / "real.txt"
    real.write_text("old content")
    target = tmp_path / "target.txt"
    os.symlink(real, target)

    converted = ConvertedFile(path=Path("target.txt"), source=source)
    action, backup = _deploy_copy(converted, target)

    assert action == "created"
    assert backup is None
    assert not target.is_symlink()
    assert target.read_text() == "source content"


def test_deploy_copy_skips_identical_file(tmp_path: Path) -> None:
    """_deploy_copy returns 'skipped' when source and target have identical content."""
    source = tmp_path / "source.txt"
    source.write_text("identical content")
    target = tmp_path / "target.txt"
    target.write_text("identical content")

    converted = ConvertedFile(path=Path("target.txt"), source=source)
    action, backup = _deploy_copy(converted, target)

    assert action == "skipped"
    assert backup is None


# ── _predict_action edge cases ────────────────────────────────────────────────


def test_predict_action_merge_always_returns_updated(tmp_path: Path) -> None:
    """_predict_action always returns 'updated' for merge=True when target exists."""
    target = tmp_path / "config.json"
    target.write_text("{}")
    converted = ConvertedFile(path=Path("config.json"), content='{"a": 1}', merge=True)

    action = _predict_action(converted, target, InstallStrategy.GENERATE)

    assert action == "updated"


def test_predict_action_content_with_symlink_target_returns_updated(tmp_path: Path) -> None:
    """_predict_action returns 'updated' when target is a symlink and content is being written."""
    real = tmp_path / "real.txt"
    real.write_text("same content")
    target = tmp_path / "link.txt"
    os.symlink(real, target)

    converted = ConvertedFile(path=Path("link.txt"), content="same content")
    action = _predict_action(converted, target, InstallStrategy.GENERATE)

    assert action == "updated"


def test_predict_action_copy_dir_equal_returns_skipped(tmp_path: Path) -> None:
    """_predict_action returns 'skipped' for COPY + identical directories."""
    source = tmp_path / "source"
    source.mkdir()
    (source / "file.txt").write_text("content")
    target = tmp_path / "target"
    target.mkdir()
    (target / "file.txt").write_text("content")

    converted = ConvertedFile(path=Path("target"), source=source, is_directory=True)
    action = _predict_action(converted, target, InstallStrategy.COPY)

    assert action == "skipped"


def test_predict_action_copy_dir_diff_returns_updated(tmp_path: Path) -> None:
    """_predict_action returns 'updated' for COPY + directories with different content."""
    source = tmp_path / "source"
    source.mkdir()
    (source / "file.txt").write_text("new content")
    target = tmp_path / "target"
    target.mkdir()
    (target / "file.txt").write_text("old content")

    converted = ConvertedFile(path=Path("target"), source=source, is_directory=True)
    action = _predict_action(converted, target, InstallStrategy.COPY)

    assert action == "updated"


def test_predict_action_copy_symlink_target_returns_updated(tmp_path: Path) -> None:
    """_predict_action returns 'updated' for COPY when a symlink is at the target."""
    source = tmp_path / "source.txt"
    source.write_text("content")
    real = tmp_path / "real.txt"
    real.write_text("content")
    target = tmp_path / "link.txt"
    os.symlink(real, target)

    converted = ConvertedFile(path=Path("link.txt"), source=source)
    action = _predict_action(converted, target, InstallStrategy.COPY)

    assert action == "updated"


def test_predict_action_symlink_matching_source_returns_skipped(tmp_path: Path) -> None:
    """_predict_action returns 'skipped' when a symlink already points to the correct source."""
    source = tmp_path / "source.txt"
    source.write_text("content")
    target = tmp_path / "link.txt"
    os.symlink(source, target)

    converted = ConvertedFile(path=Path("link.txt"), source=source)
    action = _predict_action(converted, target, InstallStrategy.SYMLINK)

    assert action == "skipped"


def test_predict_action_symlink_different_source_returns_updated(tmp_path: Path) -> None:
    """_predict_action returns 'updated' when symlink points to a different source."""
    source1 = tmp_path / "source1.txt"
    source1.write_text("content")
    source2 = tmp_path / "source2.txt"
    source2.write_text("content")
    target = tmp_path / "link.txt"
    os.symlink(source1, target)

    converted = ConvertedFile(path=Path("link.txt"), source=source2)
    action = _predict_action(converted, target, InstallStrategy.SYMLINK)

    assert action == "updated"


# ── _deploy edge cases ────────────────────────────────────────────────────────


def test_deploy_dry_run_returns_predicted_action(tmp_path: Path) -> None:
    """_deploy with dry_run=True returns a predicted action without writing files."""
    base = tmp_path / "base"
    base.mkdir()
    converted = ConvertedFile(path=Path("new_file.txt"), content="hello")

    action = _deploy(converted, base, "agent", "test-agent", dry_run=True)

    assert action.action == "created"
    assert not (base / "new_file.txt").exists()


def test_deploy_rejects_path_traversal(tmp_path: Path) -> None:
    """_deploy returns an error action when the path escapes the allowed root."""
    base = tmp_path / "base"
    base.mkdir()
    # ../../outside resolves outside tmp_path (the allowed root = base's parent)
    converted = ConvertedFile(path=Path("../../outside_file.txt"), content="hack")

    action = _deploy(converted, base, "agent", "test-agent")

    assert action.action == "error"
    assert "Path traversal rejected" in action.detail


def test_deploy_no_content_no_source_returns_error(tmp_path: Path) -> None:
    """_deploy returns an error action when ConvertedFile has neither content nor source."""
    base = tmp_path / "base"
    base.mkdir()
    converted = ConvertedFile(path=Path("file.txt"), content=None, source=None, merge=False)

    action = _deploy(converted, base, "agent", "test-agent")

    assert action.action == "error"
    assert "No content or source" in action.detail


def test_deploy_exception_during_write_returns_error_action(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """_deploy returns an error action when the underlying write raises an exception."""
    base = tmp_path / "base"
    base.mkdir()

    def _raise(*args: object, **kwargs: object) -> None:
        raise OSError("disk full")

    monkeypatch.setattr("agentmux.core.sync.write_file", _raise)
    converted = ConvertedFile(path=Path("file.txt"), content="data")

    action = _deploy(converted, base, "agent", "test-agent")

    assert action.action == "error"
    assert "disk full" in action.detail


# ── sync_setup: no [tools] section ───────────────────────────────────────────


def test_sync_no_tools_configured_uses_auto_detect(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """When setup has no [tools] section, sync auto-detects installed adapters."""
    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]\nname = "test"\n\n[components]\nagents = ["agents/*.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.setattr(cursor_mod.CursorAdapter, "detect", lambda self: True)

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

    assert any(a.action != "error" for a in actions)


def test_sync_no_tools_skips_undetected_adapters(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """When setup has no [tools], adapters that don't detect are skipped."""
    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]\nname = "test"\n\n[components]\nagents = ["agents/*.md"]'
    )

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

    from agentmux.adapters import claude_code as cc_mod
    from agentmux.adapters import cursor as cursor_mod
    from agentmux.adapters import gemini as gemini_mod
    from agentmux.adapters import opencode as opencode_mod

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

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

    assert actions == []


# ── sync_setup: MCP deployment ────────────────────────────────────────────────


def test_sync_with_mcp_deploys_mcp_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """sync_setup deploys MCP config files via convert_mcp when mcps is configured."""
    root = tmp_path / "setup"
    root.mkdir()
    (root / "mcp.json").write_text('{"mcpServers": {"myserver": {"command": "node"}}}')
    (root / "agentmux.toml").write_text(
        """\
[setup]
name = "test"

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

[components]
mcps = "mcp.json"
"""
    )

    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)

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

    mcp_actions = [a for a in actions if a.component_type == "mcp"]
    assert len(mcp_actions) > 0
