"""Tests for dev CLI commands."""

from __future__ import annotations

import subprocess
from pathlib import Path
from unittest.mock import patch

import pytest
from typer.testing import CliRunner

from agent_cli.cli import app
from agent_cli.dev._branch_name import (
    _build_branch_naming_prompt,
    _extract_branch_from_claude_output,
    _extract_branch_from_codex_output,
    _extract_branch_from_gemini_output,
    _normalize_ai_branch_candidate,
    generate_ai_branch_name,
    generate_random_branch_name,
)
from agent_cli.dev.cleanup import RemoveWorktreeResult
from agent_cli.dev.cli import _clean_no_commits_worktrees
from agent_cli.dev.cli import app as dev_app
from agent_cli.dev.coding_agents.base import CodingAgent
from agent_cli.dev.launch import (
    _format_env_prefix,
    get_agent_env,
    get_config_agent_args,
    get_config_agent_env,
)
from agent_cli.dev.terminals import TerminalHandle
from agent_cli.dev.worktree import CreateWorktreeResult, WorktreeInfo

runner = CliRunner(env={"NO_COLOR": "1", "TERM": "dumb"})


class TestGenerateBranchName:
    """Tests for generate_random_branch_name function."""

    def test_generates_adjective_noun(self) -> None:
        """Generates name in adjective-noun format."""
        name = generate_random_branch_name()
        parts = name.split("-")
        assert len(parts) >= 2

    def test_avoids_existing_branches(self) -> None:
        """Adds suffix to avoid existing branches."""
        existing: set[str] = {"happy-fox", "happy-fox-2"}
        # Run multiple times to ensure it generates unique names
        names = set()
        for _ in range(10):
            name = generate_random_branch_name(existing)
            assert name not in existing
            names.add(name)

    def test_deterministic_with_collision(self) -> None:
        """Adds numeric suffix on collision."""
        # This test is a bit tricky since names are random
        # We just verify it doesn't crash with a full set
        existing: set[str] = set()
        name = generate_random_branch_name(existing)
        assert name  # Non-empty

    def test_avoids_existing_repo_branches(self) -> None:
        """Adds suffix when branch exists in repo refs (not just worktrees)."""
        with (
            patch("agent_cli.dev._branch_name.random.choice", side_effect=["happy", "fox"]),
            patch(
                "agent_cli.dev._branch_name._branch_exists_in_repo",
                side_effect=lambda _repo, branch: branch == "happy-fox",
            ),
        ):
            name = generate_random_branch_name(repo_root=Path("/repo"))
        assert name == "happy-fox-2"

    def test_random_fallback_checks_availability(self) -> None:
        """Random fallback skips names that already exist."""
        # All sequential suffixes (2-99) are taken
        existing = {"happy-fox"} | {f"happy-fox-{i}" for i in range(2, 100)}
        with (
            patch("agent_cli.dev._branch_name.random.choice", side_effect=["happy", "fox"]),
            patch("agent_cli.dev._branch_name.random.randint", side_effect=[500, 501]),
        ):
            name = generate_random_branch_name(existing | {"happy-fox-500"})
        assert name == "happy-fox-501"


class TestAiBranchNameParsers:
    """Tests for AI branch-name response parsing helpers."""

    def test_extract_branch_from_claude_output(self) -> None:
        """Claude parser prefers structured_output branch."""
        output = (
            '{"type":"result","result":"ignored",'
            '"structured_output":{"branch":"feat/login-retry"}}\n'
        )
        assert _extract_branch_from_claude_output(output) == "feat/login-retry"

    def test_extract_branch_from_codex_output(self) -> None:
        """Codex parser extracts agent_message text from JSONL."""
        output = (
            '{"type":"turn.started"}\n'
            '{"type":"item.completed","item":{"type":"reasoning","text":"thinking"}}\n'
            '{"type":"item.completed","item":{"type":"agent_message","text":"fix/login-retry"}}'
        )
        assert _extract_branch_from_codex_output(output) == "fix/login-retry"

    def test_extract_branch_from_gemini_output(self) -> None:
        """Gemini parser handles non-JSON preamble lines."""
        output = 'Hook registry initialized\n{"response":"chore/update-tests"}\n'
        assert _extract_branch_from_gemini_output(output) == "chore/update-tests"


class TestAiBranchNameNormalization:
    """Tests for AI branch-name normalization and validation."""

    def test_normalize_branch_candidate(self) -> None:
        """Normalizes markdown/spacing and validates via git check-ref-format."""
        with patch("agent_cli.dev._branch_name.subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess([], 0, "", "")
            branch = _normalize_ai_branch_candidate("`Feature/Login Retry Logic`", Path("/repo"))
            assert branch == "feature/login-retry-logic"

    def test_normalize_returns_none_when_invalid(self) -> None:
        """Invalid names are rejected when git check-ref-format fails."""
        with patch("agent_cli.dev._branch_name.subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess([], 1, "", "invalid")
            branch = _normalize_ai_branch_candidate("invalid branch name", Path("/repo"))
            assert branch is None


class TestAiBranchNamingPrompt:
    """Tests for branch naming prompt construction."""

    def test_prompt_includes_no_tools_instruction(self) -> None:
        """Prompt explicitly forbids tool usage for faster headless responses."""
        prompt = _build_branch_naming_prompt(
            repo_root=Path("/repo"),
            prompt="Refactor auth flow",
            from_ref="origin/main",
        )
        assert "Do not use tools, do not inspect files" in prompt
        assert "Task: Refactor auth flow" in prompt

    def test_prompt_without_task_uses_generic_fallback(self) -> None:
        """Missing task uses a generic fallback instead of asking for repo context."""
        prompt = _build_branch_naming_prompt(
            repo_root=Path("/repo"),
            prompt=None,
            from_ref=None,
        )
        assert "Task: General maintenance task." in prompt
        assert "Use repository context" not in prompt


class TestGenerateAiBranchName:
    """Tests for AI branch-name generation orchestration."""

    def test_uses_second_agent_if_first_fails(self) -> None:
        """Falls through to next available agent when one fails."""
        with (
            patch(
                "agent_cli.dev._branch_name.shutil.which",
                side_effect=lambda name: "/usr/bin/bin" if name in {"claude", "codex"} else None,
            ),
            patch(
                "agent_cli.dev._branch_name._generate_branch_name_with_agent",
                side_effect=[None, "feat/login-retry"],
            ),
        ):
            branch = generate_ai_branch_name(
                Path("/repo"),
                set(),
                "Fix login retries",
                None,
                None,
                20.0,
            )
        assert branch == "feat/login-retry"

    def test_returns_none_when_no_agents_available(self) -> None:
        """Returns None when no agents are installed."""
        with patch("agent_cli.dev._branch_name.shutil.which", return_value=None):
            branch = generate_ai_branch_name(
                Path("/repo"),
                set(),
                "Fix login retries",
                None,
                None,
                20.0,
            )
        assert branch is None

    def test_adds_suffix_when_ai_name_exists_in_repo(self) -> None:
        """AI-generated branch gets de-duplicated against existing git refs."""
        with (
            patch("agent_cli.dev._branch_name.shutil.which", return_value="/usr/bin/claude"),
            patch(
                "agent_cli.dev._branch_name._generate_branch_name_with_agent",
                return_value="feat/login-retry",
            ),
            patch(
                "agent_cli.dev._branch_name._branch_exists_in_repo",
                side_effect=lambda _repo, branch: branch == "feat/login-retry",
            ),
        ):
            branch = generate_ai_branch_name(
                Path("/repo"),
                set(),
                "Fix login retries",
                None,
                "claude",
                20.0,
            )
        assert branch == "feat/login-retry-2"


class TestDevClean:
    """Tests for dev clean command."""

    def test_clean_forwards_force_to_modes(self, tmp_path: Path) -> None:
        """--force is forwarded to both merged and no-commits clean modes."""
        base_dir = tmp_path / "repo-worktrees"
        base_dir.mkdir()

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.cli.subprocess.run",
                return_value=subprocess.CompletedProcess([], 0, "", ""),
            ),
            patch("agent_cli.dev.worktree.resolve_worktree_base_dir", return_value=base_dir),
            patch("agent_cli.dev.cli._clean_merged_worktrees") as mock_merged,
            patch("agent_cli.dev.cli._clean_no_commits_worktrees") as mock_no_commits,
        ):
            result = runner.invoke(
                app,
                ["dev", "clean", "--merged", "--no-commits", "--force", "--yes"],
            )

        assert result.exit_code == 0
        assert mock_merged.call_args.kwargs["force"] is True
        assert mock_no_commits.call_args.kwargs["force"] is True

    def test_clean_no_commits_force_passed_to_remove_worktree(self) -> None:
        """No-commits cleaner passes force through to worktree removal."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )
        with (
            patch("agent_cli.dev.cleanup.find_worktrees_with_no_commits", return_value=[wt]),
            patch(
                "agent_cli.dev.cleanup.remove_worktrees",
                return_value=[RemoveWorktreeResult(name="feature", success=True)],
            ) as mock_remove,
        ):
            _clean_no_commits_worktrees(
                Path("/repo"),
                dry_run=False,
                yes=True,
                force=True,
            )

        assert mock_remove.call_args.kwargs["force"] is True


class TestDevNewBranchNaming:
    """Tests for branch naming behavior in `dev new`."""

    def test_new_uses_ai_branch_name_when_enabled(self, tmp_path: Path) -> None:
        """--branch-name-mode ai uses AI-generated branch names."""
        wt_path = tmp_path / "repo-worktrees" / "feat-login-retry"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.list_worktrees", return_value=[]),
            patch(
                "agent_cli.dev.cli.generate_ai_branch_name",
                return_value="feat/login-retry",
            ) as mock_ai,
            patch(
                "agent_cli.dev.cli.generate_random_branch_name",
                return_value="happy-fox",
            ) as mock_random,
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feat/login-retry",
                ),
            ),
            patch(
                "agent_cli.dev.cli.write_prompt_to_worktree",
                return_value=wt_path / ".claude/TASK.md",
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "--branch-name-mode",
                    "ai",
                    "--prompt",
                    "Fix login retries",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert "AI-generated branch name: feat/login-retry" in result.output
        mock_ai.assert_called_once()
        mock_random.assert_not_called()

    def test_new_uses_agent_for_branch_naming_when_supported(self, tmp_path: Path) -> None:
        """When --branch-name-agent is omitted, --agent is used if supported."""
        wt_path = tmp_path / "repo-worktrees" / "feat-login-retry"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.list_worktrees", return_value=[]),
            patch(
                "agent_cli.dev.cli.generate_ai_branch_name",
                return_value="feat/login-retry",
            ) as mock_ai,
            patch(
                "agent_cli.dev.cli.generate_random_branch_name",
                return_value="happy-fox",
            ) as mock_random,
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feat/login-retry",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "--branch-name-mode",
                    "ai",
                    "--agent",
                    "codex",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        mock_random.assert_not_called()
        mock_ai.assert_called_once_with(
            Path("/repo"),
            set(),
            None,
            None,
            "codex",
            20.0,
        )

    def test_new_with_agent_alias_warns_and_uses_agent(self, tmp_path: Path) -> None:
        """Deprecated --with-agent should still work for now."""
        wt_path = tmp_path / "repo-worktrees" / "feat-login-retry"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.list_worktrees", return_value=[]),
            patch(
                "agent_cli.dev.cli.generate_ai_branch_name",
                return_value="feat/login-retry",
            ) as mock_ai,
            patch(
                "agent_cli.dev.cli.generate_random_branch_name",
                return_value="happy-fox",
            ) as mock_random,
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feat/login-retry",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "--branch-name-mode",
                    "ai",
                    "--with-agent",
                    "codex",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert "deprecated" in result.output.lower()
        mock_random.assert_not_called()
        mock_ai.assert_called_once_with(
            Path("/repo"),
            set(),
            None,
            None,
            "codex",
            20.0,
        )

    def test_new_falls_back_to_random_when_ai_fails(self, tmp_path: Path) -> None:
        """AI naming failure falls back to random naming."""
        wt_path = tmp_path / "repo-worktrees" / "happy-fox"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.list_worktrees", return_value=[]),
            patch(
                "agent_cli.dev.cli.generate_ai_branch_name",
                return_value=None,
            ),
            patch(
                "agent_cli.dev.cli.generate_random_branch_name",
                return_value="happy-fox",
            ) as mock_random,
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="happy-fox",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent", return_value=None),
        ):
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "--branch-name-mode",
                    "ai",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert "Falling back to random naming" in result.output
        assert "Generated branch name: happy-fox" in result.output
        mock_random.assert_called_once()

    def test_new_auto_without_prompt_uses_random(self, tmp_path: Path) -> None:
        """Auto mode without prompt should not call AI naming."""
        wt_path = tmp_path / "repo-worktrees" / "happy-fox"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.list_worktrees", return_value=[]),
            patch("agent_cli.dev.cli.generate_ai_branch_name") as mock_ai,
            patch("agent_cli.dev.cli.generate_random_branch_name", return_value="happy-fox"),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="happy-fox",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent", return_value=None),
        ):
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "--branch-name-mode",
                    "auto",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        mock_ai.assert_not_called()

    def test_new_uses_dev_config_defaults_for_branch_naming(self, tmp_path: Path) -> None:
        """[dev] defaults should apply to `dev new` options via parent callback."""
        config_path = tmp_path / "config.toml"
        config_path.write_text(
            """[dev]
branch_name_mode = "ai"
branch_name_agent = "codex"
branch_name_timeout = 7.5
setup = false
copy_env = false
fetch = false
direnv = false
""",
        )
        wt_path = tmp_path / "repo-worktrees" / "feat-login-retry"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.list_worktrees", return_value=[]),
            patch(
                "agent_cli.dev.cli.generate_ai_branch_name",
                return_value="feat/login-retry",
            ) as mock_ai,
            patch("agent_cli.dev.cli.generate_random_branch_name") as mock_random,
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feat/login-retry",
                ),
            ) as mock_create,
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent", return_value=None),
        ):
            result = runner.invoke(app, ["dev", "--config", str(config_path), "new"])

        assert result.exit_code == 0
        mock_random.assert_not_called()
        mock_ai.assert_called_once_with(
            Path("/repo"),
            set(),
            None,
            None,
            "codex",
            7.5,
        )
        assert mock_create.call_args.kwargs["fetch"] is False

    def test_new_uses_start_agent_from_dev_config(self, tmp_path: Path) -> None:
        """[dev].start_agent should trigger an interactive launch by default."""
        config_path = tmp_path / "config.toml"
        config_path.write_text(
            """[dev]
start_agent = true
setup = false
copy_env = false
fetch = false
direnv = false
""",
        )
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(app, ["dev", "--config", str(config_path), "new", "feature"])

        assert result.exit_code == 0
        assert mock_resolve_agent.call_args.args == (True, None, None)

    def test_new_shows_tmux_handle_when_requested(self, tmp_path: Path) -> None:
        """`dev new --start-agent -m tmux` shows the returned tmux handle."""
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch(
                "agent_cli.dev.cli.launch_agent",
                return_value=TerminalHandle("tmux", "%42", "agent-cli-repo-1234"),
            ) as mock_launch,
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "feature",
                    "--start-agent",
                    "-m",
                    "tmux",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert mock_launch.call_args.kwargs["multiplexer_name"] == "tmux"
        assert "Agent Handle:" in result.output
        assert "%42" in result.output
        assert "tmux attach -t agent-cli-repo-1234" in result.output

    def test_new_tmux_session_implies_tmux_and_normalizes(self, tmp_path: Path) -> None:
        """`--tmux-session` trims whitespace and forces tmux."""
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch(
                "agent_cli.dev.cli.launch_agent",
                return_value=TerminalHandle("tmux", "%42", "my_session name"),
            ) as mock_launch,
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "feature",
                    "--start-agent",
                    "--tmux-session",
                    "  my_session name  ",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert mock_launch.call_args.kwargs["multiplexer_name"] == "tmux"
        assert mock_launch.call_args.kwargs["tmux_session"] == "my_session name"
        assert "tmux attach -t 'my_session name'" in result.output

    def test_new_tmux_session_does_not_imply_start_agent(self, tmp_path: Path) -> None:
        """`--tmux-session` is only a placement flag for `dev new`."""
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent", return_value=None) as mock_resolve_agent,
            patch("agent_cli.dev.cli.launch_agent") as mock_launch,
        ):
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "feature",
                    "--tmux-session",
                    "shared-session",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert mock_resolve_agent.call_args.args == (False, None, None)
        mock_launch.assert_not_called()

    def test_new_short_start_agent_alias_warns(self, tmp_path: Path) -> None:
        """Legacy `-a` should still work but warn to use --start-agent."""
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "feature",
                    "-a",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

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

    def test_new_fails_before_creating_worktree_when_no_agent_is_available(
        self,
        tmp_path: Path,
    ) -> None:
        """Explicit agent launches should fail fast before creating side effects."""
        prompt_file = tmp_path / "task.md"
        prompt_file.write_text("Fix login retries")

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.cli.resolve_agent", return_value=None),
            patch("agent_cli.dev.worktree.create_worktree") as mock_create,
        ):
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "my-feature",
                    "--multiplexer",
                    "tmux",
                    "--prompt-file",
                    str(prompt_file),
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 1
        assert "No AI coding agents found" in result.output
        assert "Success" not in result.output
        mock_create.assert_not_called()

    def test_new_agent_auto_uses_auto_resolution(self, tmp_path: Path) -> None:
        """`--agent auto` should launch with default/auto-detected resolution."""
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "feature",
                    "--agent",
                    "auto",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert mock_resolve_agent.call_args.args == (True, None, None)

    def test_new_fails_before_creating_worktree_when_requested_agent_is_unavailable(self) -> None:
        """Unavailable explicit agents should abort before worktree creation."""
        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.worktree.create_worktree") as mock_create,
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.name = "codex"
            mock_agent.install_url = "https://example.com/codex"
            mock_agent.is_available.return_value = False

            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "my-feature",
                    "--agent",
                    "codex",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 1
        assert "codex is not installed. Install from: https://example.com/codex" in result.output
        assert "Success" not in result.output
        mock_create.assert_not_called()

    def test_new_rejects_empty_prompt(self) -> None:
        """Explicit empty prompts should error instead of silently becoming no-op."""
        with patch("agent_cli.dev.cli._ensure_git_repo") as mock_ensure_repo:
            result = runner.invoke(app, ["dev", "new", "my-feature", "--prompt", "   "])

        assert result.exit_code == 1
        assert "--prompt cannot be empty" in result.output
        mock_ensure_repo.assert_not_called()

    def test_new_rejects_empty_prompt_file(self, tmp_path: Path) -> None:
        """Empty prompt files should fail before any worktree setup starts."""
        prompt_file = tmp_path / "empty.md"
        prompt_file.write_text(" \n")

        with patch("agent_cli.dev.cli._ensure_git_repo") as mock_ensure_repo:
            result = runner.invoke(
                app,
                ["dev", "new", "my-feature", "--prompt-file", str(prompt_file)],
            )

        assert result.exit_code == 1
        assert f"Prompt file is empty: {prompt_file}" in result.output
        mock_ensure_repo.assert_not_called()

    def test_new_rejects_empty_tmux_session(self) -> None:
        """Empty tmux session names should fail before repo/worktree work starts."""
        with patch("agent_cli.dev.cli._ensure_git_repo") as mock_ensure_repo:
            result = runner.invoke(app, ["dev", "new", "my-feature", "--tmux-session", "  "])

        assert result.exit_code == 1
        assert "--tmux-session cannot be empty" in result.output
        mock_ensure_repo.assert_not_called()

    @pytest.mark.parametrize("tmux_session", ["batch.1", "batch:1"])
    def test_new_rejects_tmux_session_with_illegal_characters(self, tmux_session: str) -> None:
        """Tmux session names with tmux-illegal characters should fail early."""
        with patch("agent_cli.dev.cli._ensure_git_repo") as mock_ensure_repo:
            result = runner.invoke(
                app, ["dev", "new", "my-feature", "--tmux-session", tmux_session]
            )

        assert result.exit_code == 1
        assert "tmux session names cannot contain '.' or ':'" in result.output
        mock_ensure_repo.assert_not_called()

    def test_new_skips_launch_preparation_when_hooks_are_disabled(self, tmp_path: Path) -> None:
        """`--no-hooks` should bypass built-in preparation and configured hooks."""
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch") as mock_prepare,
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                [
                    "dev",
                    "new",
                    "feature",
                    "--start-agent",
                    "--no-hooks",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert mock_prepare.call_count == 1
        assert mock_prepare.call_args.kwargs["hooks_enabled"] is False

    def test_new_uses_hooks_from_explicit_config_file(self, tmp_path: Path) -> None:
        """`dev --config ... new` should apply hook config from that file."""
        wt_path = tmp_path / "repo-worktrees" / "feature"
        wt_path.mkdir(parents=True)
        config_path = tmp_path / "agent-cli-config.toml"
        hook_path = tmp_path / "pre-launch.sh"
        hook_path.write_text("#!/bin/sh\nexit 0\n")
        config_path.write_text(f'[dev.hooks]\npre_launch = ["{hook_path.as_posix()}"]\n')

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch(
                "agent_cli.dev.worktree.create_worktree",
                return_value=CreateWorktreeResult(
                    success=True,
                    path=wt_path,
                    branch="feature",
                ),
            ),
            patch("agent_cli.dev.cli.resolve_editor", return_value=None),
            patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent,
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.launch_agent", return_value=None),
            patch(
                "agent_cli.dev.hooks.subprocess.run",
                return_value=subprocess.CompletedProcess([], 0, "", ""),
            ) as mock_run,
        ):
            mock_agent = mock_resolve_agent.return_value
            mock_agent.name = "codex"
            mock_agent.is_available.return_value = True
            mock_agent.prepare_launch.return_value = None
            result = runner.invoke(
                app,
                [
                    "dev",
                    "--config",
                    str(config_path),
                    "new",
                    "feature",
                    "--start-agent",
                    "--no-setup",
                    "--no-copy-env",
                    "--no-fetch",
                    "--no-direnv",
                ],
            )

        assert result.exit_code == 0
        assert mock_run.call_count == 1
        assert mock_run.call_args.args[0] == [str(hook_path)]
        assert mock_run.call_args.kwargs["cwd"] == wt_path


class TestDevHelp:
    """Tests for dev command help."""

    def test_dev_help(self) -> None:
        """Dev command shows help."""
        result = runner.invoke(app, ["dev", "--help"])
        assert result.exit_code == 0
        assert "Parallel development environment manager" in result.output
        assert "new" in result.output
        assert "list" in result.output
        assert "rm" in result.output


class TestDevAgent:
    """Tests for `dev agent`."""

    def test_agent_can_launch_in_requested_tmux(self) -> None:
        """`dev agent -m tmux` delegates to the tmux launch path and shows the handle."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt),
            patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch(
                "agent_cli.dev.cli.launch_agent",
                return_value=TerminalHandle("tmux", "%42", "agent-cli-repo-1234"),
            ) as mock_launch,
        ):
            current_agent = mock_detect_current.return_value
            current_agent.name = "codex"
            current_agent.is_available.return_value = True
            result = runner.invoke(app, ["dev", "agent", "feature", "-m", "tmux"])

        assert result.exit_code == 0
        assert mock_launch.call_args.kwargs["multiplexer_name"] == "tmux"
        assert "tmux handle: %42" in result.output

    def test_agent_tmux_session_implies_tmux_and_normalizes(self) -> None:
        """`dev agent --tmux-session` trims whitespace and forces tmux."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt),
            patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch(
                "agent_cli.dev.cli.launch_agent",
                return_value=TerminalHandle("tmux", "%42", "my_session name"),
            ) as mock_launch,
        ):
            current_agent = mock_detect_current.return_value
            current_agent.name = "codex"
            current_agent.is_available.return_value = True
            result = runner.invoke(
                app,
                ["dev", "agent", "feature", "--tmux-session", "  my_session name  "],
            )

        assert result.exit_code == 0
        assert mock_launch.call_args.kwargs["multiplexer_name"] == "tmux"
        assert mock_launch.call_args.kwargs["tmux_session"] == "my_session name"
        assert "tmux attach -t 'my_session name'" in result.output

    def test_agent_quotes_tmux_attach_hint(self) -> None:
        """Attach hint should quote session names that contain spaces."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt),
            patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch(
                "agent_cli.dev.cli.launch_agent",
                return_value=TerminalHandle("tmux", "%42", "my session"),
            ),
        ):
            current_agent = mock_detect_current.return_value
            current_agent.name = "codex"
            current_agent.is_available.return_value = True
            result = runner.invoke(app, ["dev", "agent", "feature", "-m", "tmux"])

        assert result.exit_code == 0
        assert "tmux attach -t 'my session'" in result.output

    def test_agent_rejects_empty_tmux_session(self) -> None:
        """Empty tmux session names should fail before repo/worktree resolution."""
        with patch("agent_cli.dev.cli._ensure_git_repo") as mock_ensure_repo:
            result = runner.invoke(app, ["dev", "agent", "feature", "--tmux-session", "  "])

        assert result.exit_code == 1
        assert "--tmux-session cannot be empty" in result.output
        mock_ensure_repo.assert_not_called()

    @pytest.mark.parametrize("tmux_session", ["batch.1", "batch:1"])
    def test_agent_rejects_tmux_session_with_illegal_characters(self, tmux_session: str) -> None:
        """Tmux session names with tmux-illegal characters should fail early."""
        with patch("agent_cli.dev.cli._ensure_git_repo") as mock_ensure_repo:
            result = runner.invoke(
                app,
                ["dev", "agent", "feature", "--tmux-session", tmux_session],
            )

        assert result.exit_code == 1
        assert "tmux session names cannot contain '.' or ':'" in result.output
        mock_ensure_repo.assert_not_called()

    def test_agent_rejects_empty_prompt_file(self, tmp_path: Path) -> None:
        """Empty prompt files should fail before repo/worktree resolution."""
        prompt_file = tmp_path / "empty.md"
        prompt_file.write_text("\n")

        with patch("agent_cli.dev.cli._ensure_git_repo") as mock_ensure_repo:
            result = runner.invoke(
                app,
                ["dev", "agent", "feature", "--prompt-file", str(prompt_file)],
            )

        assert result.exit_code == 1
        assert f"Prompt file is empty: {prompt_file}" in result.output
        mock_ensure_repo.assert_not_called()

    def test_agent_with_agent_flag(self) -> None:
        """`dev agent foo -a claude` selects the named agent."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt),
            patch("agent_cli.dev.cli.coding_agents.get_agent") as mock_get_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.os.chdir"),
            patch("agent_cli.dev.cli.subprocess.run"),
        ):
            mock_agent = mock_get_agent.return_value
            mock_agent.name = "claude"
            mock_agent.is_available.return_value = True
            mock_agent.launch_command.return_value = ["claude"]
            result = runner.invoke(
                app,
                ["dev", "agent", "feature", "-a", "claude"],
            )

        assert result.exit_code == 0
        mock_get_agent.assert_called_once_with("claude")

    def test_agent_deprecated_flag_warns(self) -> None:
        """`dev agent foo --with-agent claude` works but prints a deprecation warning."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt),
            patch("agent_cli.dev.cli.coding_agents.get_agent") as mock_get_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.os.chdir"),
            patch("agent_cli.dev.cli.subprocess.run"),
        ):
            mock_agent = mock_get_agent.return_value
            mock_agent.name = "claude"
            mock_agent.is_available.return_value = True
            mock_agent.launch_command.return_value = ["claude"]
            result = runner.invoke(
                app,
                ["dev", "agent", "feature", "--with-agent", "claude"],
            )

        assert result.exit_code == 0
        assert "deprecated" in result.output.lower()
        mock_get_agent.assert_called_once_with("claude")

    def test_agent_dot_resolves_current_worktree(self) -> None:
        """`dev agent .` resolves '.' to the current worktree via find_worktree_by_name."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt) as mock_find,
            patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.os.chdir"),
            patch("agent_cli.dev.cli.subprocess.run"),
        ):
            current_agent = mock_detect_current.return_value
            current_agent.name = "claude"
            current_agent.is_available.return_value = True
            current_agent.launch_command.return_value = ["claude"]
            result = runner.invoke(app, ["dev", "agent", "."])

        assert result.exit_code == 0
        mock_find.assert_called_once_with(".", Path("/repo"))

    def test_agent_primary_flag_overrides_deprecated_alias(self) -> None:
        """Primary -a/--agent takes precedence over deprecated --with-agent."""
        wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt),
            patch("agent_cli.dev.cli.coding_agents.get_agent") as mock_get_agent,
            patch("agent_cli.dev.cli.prepare_agent_launch"),
            patch("agent_cli.dev.cli.merge_agent_args", return_value=None),
            patch("agent_cli.dev.cli.get_agent_env", return_value={}),
            patch("agent_cli.dev.cli.os.chdir"),
            patch("agent_cli.dev.cli.subprocess.run"),
        ):
            mock_agent = mock_get_agent.return_value
            mock_agent.name = "claude"
            mock_agent.is_available.return_value = True
            mock_agent.launch_command.return_value = ["claude"]
            result = runner.invoke(
                app,
                ["dev", "agent", "feature", "--with-agent", "claude", "-a", "codex"],
            )

        assert result.exit_code == 0
        assert "deprecated" in result.output.lower()
        mock_get_agent.assert_called_once_with("codex")


class TestDevAgents:
    """Tests for dev agents command."""

    def test_list_agents(self) -> None:
        """List all agents."""
        result = runner.invoke(dev_app, ["agents"])
        assert result.exit_code == 0
        assert "claude" in result.output.lower()
        assert "aider" in result.output.lower()


class TestDevEditors:
    """Tests for dev editors command."""

    def test_list_editors(self) -> None:
        """List all editors."""
        result = runner.invoke(dev_app, ["editors"])
        assert result.exit_code == 0
        assert "vscode" in result.output.lower()
        assert "neovim" in result.output.lower()


class TestDevTerminals:
    """Tests for dev terminals command."""

    def test_list_terminals(self) -> None:
        """List all terminals."""
        result = runner.invoke(dev_app, ["terminals"])
        assert result.exit_code == 0
        assert "tmux" in result.output.lower()
        assert "zellij" in result.output.lower()


class TestDevDoctor:
    """Tests for dev doctor command."""

    def test_doctor_shows_status(self) -> None:
        """Doctor command shows system status."""
        result = runner.invoke(app, ["dev", "doctor"])
        assert result.exit_code == 0
        assert "Git" in result.output
        assert "Editors" in result.output
        assert "AI Coding Agents" in result.output
        assert "Terminals" in result.output


class TestDevList:
    """Tests for dev list command."""

    def test_list_requires_git_repo(self) -> None:
        """List requires being in a git repo."""
        with patch("agent_cli.dev.worktree.get_main_repo_root", return_value=None):
            result = runner.invoke(app, ["dev", "list"])
            # Should show error about not being in git repo
            assert "git" in result.output.lower() or result.exit_code != 0

    def test_list_shows_worktrees(self) -> None:
        """List shows worktrees in table format."""
        mock_worktrees = [
            WorktreeInfo(
                path=Path("/repo"),
                branch="main",
                commit="abc",
                is_main=True,
                is_detached=False,
                is_locked=False,
                is_prunable=False,
            ),
            WorktreeInfo(
                path=Path("/repo-worktrees/feature"),
                branch="feature",
                commit="def",
                is_main=False,
                is_detached=False,
                is_locked=False,
                is_prunable=False,
            ),
        ]

        with (
            patch("agent_cli.dev.worktree.get_main_repo_root", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.git_available", return_value=True),
            patch("agent_cli.dev.worktree.list_worktrees", return_value=mock_worktrees),
        ):
            result = runner.invoke(app, ["dev", "list"])
            assert result.exit_code == 0
            assert "main" in result.output
            assert "feature" in result.output


class TestDevPath:
    """Tests for dev path command."""

    def test_path_prints_worktree_path(self) -> None:
        """Path command prints worktree path."""
        mock_wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.worktree.get_main_repo_root", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.git_available", return_value=True),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=mock_wt),
        ):
            result = runner.invoke(app, ["dev", "path", "feature"])
            assert result.exit_code == 0
            assert "/repo-worktrees/feature" in result.output

    def test_path_not_found(self) -> None:
        """Path command shows error for unknown worktree."""
        with (
            patch("agent_cli.dev.worktree.get_main_repo_root", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.git_available", return_value=True),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=None),
        ):
            result = runner.invoke(app, ["dev", "path", "nonexistent"])
            assert result.exit_code != 0
            assert "not found" in result.output.lower()


class TestDevRm:
    """Tests for dev rm command."""

    def test_rm_force_skips_confirmation(self) -> None:
        """The --force flag skips the confirmation prompt.

        Bug fix: Previously --force only passed force to git worktree remove
        but still prompted for confirmation. Now it skips the prompt entirely.

        Evidence: typer.confirm() is only called when neither --yes nor --force
        is provided (line 845 in cli.py).
        """
        mock_wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.worktree.get_main_repo_root", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.git_available", return_value=True),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=mock_wt),
            patch(
                "agent_cli.dev.cleanup.remove_worktree",
                return_value=RemoveWorktreeResult(name="feature", success=True),
            ) as mock_remove,
        ):
            # With --force, should NOT prompt and should succeed
            result = runner.invoke(app, ["dev", "rm", "feature", "--force"])
            assert result.exit_code == 0
            assert "Removed worktree" in result.output
            # Verify cleanup.remove_worktree was called with force=True
            mock_remove.assert_called_once()
            assert mock_remove.call_args[1]["force"] is True

    def test_rm_yes_skips_confirmation(self) -> None:
        """The --yes flag skips the confirmation prompt."""
        mock_wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.worktree.get_main_repo_root", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.git_available", return_value=True),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=mock_wt),
            patch(
                "agent_cli.dev.cleanup.remove_worktree",
                return_value=RemoveWorktreeResult(name="feature", success=True),
            ) as mock_remove,
        ):
            # With --yes, should NOT prompt and should succeed
            result = runner.invoke(app, ["dev", "rm", "feature", "--yes"])
            assert result.exit_code == 0
            assert "Removed worktree" in result.output
            mock_remove.assert_called_once()

    def test_rm_without_flags_prompts(self) -> None:
        """Without --force or --yes, rm prompts for confirmation."""
        mock_wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.worktree.get_main_repo_root", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.git_available", return_value=True),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=mock_wt),
            patch(
                "agent_cli.dev.cleanup.remove_worktree",
                return_value=RemoveWorktreeResult(name="feature", success=True),
            ) as mock_remove,
        ):
            # Without --force or --yes, should prompt (and abort on 'n')
            result = runner.invoke(app, ["dev", "rm", "feature"], input="n\n")
            assert result.exit_code != 0 or "Aborted" in result.output
            # remove_worktree should NOT have been called since user said no
            mock_remove.assert_not_called()

    def test_rm_surfaces_tmux_cleanup_warnings(self) -> None:
        """Tmux cleanup failures should warn without failing the removal."""
        mock_wt = WorktreeInfo(
            path=Path("/repo-worktrees/feature"),
            branch="feature",
            commit="abc",
            is_main=False,
            is_detached=False,
            is_locked=False,
            is_prunable=False,
        )

        with (
            patch("agent_cli.dev.worktree.get_main_repo_root", return_value=Path("/repo")),
            patch("agent_cli.dev.worktree.git_available", return_value=True),
            patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=mock_wt),
            patch(
                "agent_cli.dev.cleanup.remove_worktree",
                return_value=RemoveWorktreeResult(
                    name="feature",
                    success=True,
                    warnings=["Failed to inspect tmux windows for /repo-worktrees/feature: boom"],
                ),
            ),
        ):
            result = runner.invoke(app, ["dev", "rm", "feature", "--yes"])

        assert result.exit_code == 0
        assert "Removed worktree" in result.output
        assert "Failed to inspect tmux windows" in result.output


class TestFormatEnvPrefix:
    """Tests for _format_env_prefix function."""

    def test_empty_env(self) -> None:
        """Empty env returns empty string."""
        assert _format_env_prefix({}) == ""

    def test_single_var(self) -> None:
        """Single env var is formatted correctly."""
        result = _format_env_prefix({"FOO": "bar"})
        assert result == "FOO=bar "

    def test_multiple_vars_sorted(self) -> None:
        """Multiple env vars are sorted alphabetically."""
        result = _format_env_prefix({"ZZZ": "last", "AAA": "first"})
        assert result == "AAA=first ZZZ=last "

    def test_quotes_special_chars(self) -> None:
        """Values with spaces or special chars are quoted."""
        result = _format_env_prefix({"MSG": "hello world"})
        assert result == "MSG='hello world' "

    def test_quotes_empty_value(self) -> None:
        """Empty values are quoted."""
        result = _format_env_prefix({"EMPTY": ""})
        assert result == "EMPTY='' "


class TestGetConfigAgentArgs:
    """Tests for get_config_agent_args function."""

    def test_returns_none_when_no_config(self) -> None:
        """Returns None when no agent_args in config."""
        with patch("agent_cli.dev._config.get_runtime_config", return_value={}):
            result = get_config_agent_args()
            assert result is None

    def test_returns_agent_args_nested(self) -> None:
        """Returns agent_args from nested config structure (for mocks)."""
        config = {
            "dev": {
                "agent_args": {
                    "claude": ["--dangerously-skip-permissions"],
                },
            },
        }
        with patch("agent_cli.dev._config.get_runtime_config", return_value=config):
            result = get_config_agent_args()
            assert result == {"claude": ["--dangerously-skip-permissions"]}

    def test_returns_agent_args_from_flattened_key(self) -> None:
        """Returns agent_args from flattened config key (real config loader format)."""
        config = {
            "dev.agent_args": {
                "claude": ["--dangerously-skip-permissions"],
                "aider": ["--model", "gpt-4o"],
            },
        }
        with patch("agent_cli.dev._config.get_runtime_config", return_value=config):
            result = get_config_agent_args()
            assert result == {
                "claude": ["--dangerously-skip-permissions"],
                "aider": ["--model", "gpt-4o"],
            }


class TestGetConfigAgentEnv:
    """Tests for get_config_agent_env function."""

    def test_returns_none_when_no_config(self) -> None:
        """Returns None when no agent_env in config."""
        with patch("agent_cli.dev._config.get_runtime_config", return_value={}):
            result = get_config_agent_env()
            assert result is None

    def test_returns_none_when_no_dev_section(self) -> None:
        """Returns None when no dev section in config."""
        with patch("agent_cli.dev._config.get_runtime_config", return_value={"other": {}}):
            result = get_config_agent_env()
            assert result is None

    def test_returns_agent_env(self) -> None:
        """Returns agent_env from config (nested structure for mocks)."""
        config = {
            "dev": {
                "agent_env": {
                    "claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"},
                },
            },
        }
        with patch("agent_cli.dev._config.get_runtime_config", return_value=config):
            result = get_config_agent_env()
            assert result == {"claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}}

    def test_returns_agent_env_from_flattened_keys(self) -> None:
        """Returns agent_env from flattened config keys (real config loader format)."""
        # The real config loader flattens nested dicts to dotted keys
        config = {
            "dev.agent_env.claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"},
            "dev.agent_env.aider": {"OPENAI_API_KEY": "sk-xxx"},
        }
        with patch("agent_cli.dev._config.get_runtime_config", return_value=config):
            result = get_config_agent_env()
            assert result == {
                "claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"},
                "aider": {"OPENAI_API_KEY": "sk-xxx"},
            }


class _MockAgent(CodingAgent):
    """Mock agent for testing."""

    name = "mock"
    command = "mock"

    def get_env(self) -> dict[str, str]:
        """Return mock env vars."""
        return {"BUILTIN_VAR": "builtin_value"}


class TestGetAgentEnv:
    """Tests for get_agent_env function."""

    def test_returns_builtin_env_when_no_config(self) -> None:
        """Returns agent's built-in env when no config."""
        agent = _MockAgent()
        with patch("agent_cli.dev.launch.get_config_agent_env", return_value=None):
            result = get_agent_env(agent)
            assert result == {"BUILTIN_VAR": "builtin_value"}

    def test_config_overrides_builtin(self) -> None:
        """Config env vars override built-in env vars."""
        agent = _MockAgent()
        config_env = {"mock": {"BUILTIN_VAR": "overridden", "NEW_VAR": "new_value"}}
        with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env):
            result = get_agent_env(agent)
            assert result == {"BUILTIN_VAR": "overridden", "NEW_VAR": "new_value"}

    def test_merges_builtin_and_config(self) -> None:
        """Config env vars are merged with built-in env vars."""
        agent = _MockAgent()
        config_env = {"mock": {"CONFIG_VAR": "config_value"}}
        with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env):
            result = get_agent_env(agent)
            assert result == {"BUILTIN_VAR": "builtin_value", "CONFIG_VAR": "config_value"}

    def test_ignores_other_agents(self) -> None:
        """Config for other agents is ignored."""
        agent = _MockAgent()
        config_env = {"other": {"OTHER_VAR": "other_value"}}
        with patch("agent_cli.dev.launch.get_config_agent_env", return_value=config_env):
            result = get_agent_env(agent)
            assert result == {"BUILTIN_VAR": "builtin_value"}


class TestDevInstallSkill:
    """Tests for dev install-skill command."""

    def test_install_skill_help(self) -> None:
        """Install-skill command shows help."""
        result = runner.invoke(app, ["dev", "install-skill", "--help"])
        assert result.exit_code == 0
        assert "Install Claude Code skill" in result.output
        assert "--force" in result.output

    def test_install_skill_requires_git_repo(self) -> None:
        """Install-skill requires being in a git repo."""
        with patch("agent_cli.dev.cli._get_current_repo_root", return_value=None):
            result = runner.invoke(app, ["dev", "install-skill"])
            assert result.exit_code != 0
            assert "git" in result.output.lower()

    def test_install_skill_copies_files(self, tmp_path: Path) -> None:
        """Install-skill copies skill files to .claude/skills/."""
        with patch("agent_cli.dev.cli._get_current_repo_root", return_value=tmp_path):
            result = runner.invoke(app, ["dev", "install-skill"])
            assert result.exit_code == 0
            assert "Installed skill" in result.output

            skill_dir = tmp_path / ".claude" / "skills" / "agent-cli-dev"
            assert skill_dir.exists()
            assert (skill_dir / "SKILL.md").exists()
            assert (skill_dir / "examples.md").exists()

    def test_install_skill_already_installed(self, tmp_path: Path) -> None:
        """Install-skill warns if already installed."""
        # Pre-create the skill directory
        skill_dir = tmp_path / ".claude" / "skills" / "agent-cli-dev"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("existing")

        with patch("agent_cli.dev.cli._get_current_repo_root", return_value=tmp_path):
            result = runner.invoke(app, ["dev", "install-skill"])
            assert result.exit_code == 0
            assert "already installed" in result.output.lower()

    def test_install_skill_force_overwrites(self, tmp_path: Path) -> None:
        """Install-skill --force overwrites existing files."""
        # Pre-create the skill directory
        skill_dir = tmp_path / ".claude" / "skills" / "agent-cli-dev"
        skill_dir.mkdir(parents=True)
        (skill_dir / "SKILL.md").write_text("old content")

        with patch("agent_cli.dev.cli._get_current_repo_root", return_value=tmp_path):
            result = runner.invoke(app, ["dev", "install-skill", "--force"])
            assert result.exit_code == 0
            assert "Installed skill" in result.output

            # Verify content was replaced
            content = (skill_dir / "SKILL.md").read_text()
            assert "old content" not in content
            assert "agent-cli-dev" in content
