"""Tests for CLI."""

import pytest
import typer
from pathlib import Path
from typer.testing import CliRunner
from unittest.mock import MagicMock, Mock, patch

from cafe.ui.cli import app, _setup_agents
from cafe.core.git import GitOperations
from cafe.core.types import AgentCLI, PhaseResult, PhaseStatus
from cafe.utils.config import ConfigManager


runner = CliRunner()


@pytest.fixture
def mock_git_ops() -> MagicMock:
    """Create a mock GitOperations for testing."""
    git_ops = MagicMock(spec=GitOperations)
    git_ops.get_current_branch.return_value = "test-issue"
    return git_ops


@pytest.fixture
def config_dir_with_file(tmp_path):
    """Create a config directory with a valid config.yaml file."""
    config_dir = tmp_path / ".cafe"
    config_dir.mkdir(parents=True)
    (config_dir / "config.yaml").write_text("""
agents:
  pm:
    name: Roger
    cli: copilot
  developer:
    name: David
    cli: copilot
  reviewer:
    name: Richard
    cli: copilot

auto:
  max_review_iterations: 5
""")
    return config_dir


def _create_minimal_config(tmp_path: Path):
    """Helper to create minimal config.yaml in tmp_path/.cafe/"""
    from tests.conftest import create_minimal_config
    create_minimal_config(tmp_path)


class TestSetupAgents:
    """Test agent setup functionality."""

    def test_setup_agents_with_default_config(self, config_dir_with_file) -> None:
        """測試使用預設設定建立 agents"""
        config_manager = ConfigManager(str(config_dir_with_file))

        agent_manager = _setup_agents(config_manager)

        # 驗證三個 agents 都已註冊
        assert "Roger" in agent_manager.agents
        assert "David" in agent_manager.agents
        assert "Richard" in agent_manager.agents

        # 驗證有預設值
        assert agent_manager.agents["Roger"].config.cli != None
        assert agent_manager.agents["David"].config.cli != None
        assert agent_manager.agents["Richard"].config.cli != None

    def test_setup_agents_with_custom_config(self, tmp_path: Path) -> None:
        """測試使用自訂設定建立 agents"""
        config_file = tmp_path / "config.yaml"
        config_manager = ConfigManager(str(config_file))

        # 設定自訂 agent 設定（使用 dict 結構而非預設 list）
        custom_config = {
            "agents": {
                "pm": {"name": "CustomPM", "cli": "gemini"},
                "developer": {"name": "CustomDev", "cli": "claude"},
                "reviewer": {"name": "Richard", "cli": "cursor-agent"},
            }
        }
        config_manager.save_config(custom_config)

        agent_manager = _setup_agents(config_manager)

        # 驗證自訂設定
        assert "CustomPM" in agent_manager.agents
        assert "CustomDev" in agent_manager.agents
        assert agent_manager.agents["CustomPM"].config.cli == AgentCLI.GEMINI
        assert agent_manager.agents["Richard"].config.cli == AgentCLI.CURSOR

    def test_setup_agents_preserves_model_from_config(self, tmp_path: Path) -> None:
        """Test that agent model from config is preserved when setting up agents."""
        config_file = tmp_path / "config.yaml"
        config_manager = ConfigManager(str(config_file))

        # Create config with model settings
        custom_config = {
            "agents": {
                "pm": {"name": "Roger", "cli": "claude", "model": "haiku"},
                "developer": {"name": "David", "cli": "claude", "model": "opus"},
                "reviewer": {"name": "Richard", "cli": "gemini", "model": "gemini-2.5-flash"},
            }
        }
        config_manager.save_config(custom_config)

        agent_manager = _setup_agents(config_manager)

        # Verify models are preserved
        assert agent_manager.agents["Roger"].config.model == "haiku"
        assert agent_manager.agents["David"].config.model == "opus"
        assert agent_manager.agents["Richard"].config.model == "gemini-2.5-flash"


class TestVersionCommand:
    """Test version command."""

    def test_version_shows_version_number(self) -> None:
        """測試 version 指令顯示版本號"""
        result = runner.invoke(app, ["version"])

        assert result.exit_code == 0
        assert "0.1.0" in result.stdout


class TestConfigCommand:
    """Test config command."""

    def test_config_list_all(self, tmp_path: Path) -> None:
        """測試列出所有設定"""
        # Change to tmp_path directory first, then set config
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            # Create basic config first
            cafe_dir = tmp_path / ".cafe"
            cafe_dir.mkdir()
            (cafe_dir / "config.yaml").write_text("agents: {}")

            # Now ConfigManager will use .cafe in current directory
            config_manager = ConfigManager()
            config_manager.set("test.key", "value")  # set() already calls save_config()

            result = runner.invoke(app, ["config"])
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0
        assert "test:" in result.stdout or "test" in result.stdout
        assert "value" in result.stdout

    def test_config_get_existing_key(self, tmp_path: Path) -> None:
        """測試取得存在設定值"""
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            # Save custom config with dict structure for agents
            custom_config = {
                "agents": {
                    "pm": {"name": "Roger"}
                }
            }
            config_manager = ConfigManager()
            config_manager.save_config(custom_config)

            result = runner.invoke(app, ["config", "get", "agents.pm.name"])
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0
        assert "Roger" in result.stdout

    def test_config_get_nonexistent_key(self, tmp_path: Path) -> None:
        """測試取得不存在設定值"""
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            _create_minimal_config(tmp_path)
            result = runner.invoke(app, ["config", "get", "nonexistent.key"])
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0
        assert "Key not found" in result.stdout

    def test_config_set_value(self, tmp_path: Path) -> None:
        """測試設定值"""
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            _create_minimal_config(tmp_path)
            result = runner.invoke(app, ["config", "set", "test.key", "test_value"])

            # 驗證設定已儲存
            config_manager = ConfigManager()
            assert config_manager.get("test.key") == "test_value"
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0
        assert "Set test.key = test_value" in result.stdout

    def test_config_without_args_shows_help(self, tmp_path: Path) -> None:
        """測試沒有參數時顯示所有設定"""
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            _create_minimal_config(tmp_path)
            result = runner.invoke(app, ["config"])
        finally:
            os.chdir(old_cwd)

        assert result.exit_code == 0
        # Without args, config command shows all configuration
        assert "agents" in result.stdout or "Configuration" in result.stdout


class TestPlanCommand:
    """Test plan command."""

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli.select_template")
    @patch("cafe.ui.cli.PlanPhase")
    def test_plan_local_mode_success(
        self,
        mock_plan_phase: Mock,
        mock_select_template: Mock,
        mock_git_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 plan 指令 local mode 成功執行"""
        # Setup: Create config.yaml
        _create_minimal_config(tmp_path)

        # Setup: Create versioned spec file in the expected location (new structure)
        branch_name = "test-issue"
        spec_dir = tmp_path / ".cafe" / "issues" / branch_name / "spec"
        iter_dir = spec_dir / "iteration_001"
        iter_dir.mkdir(parents=True, exist_ok=True)
        spec_file = iter_dir / "output.md"
        spec_file.write_text("# Spec\n\n## 開發指南\nGuide")

        # Create a default template
        template_dir = tmp_path / ".cafe" / "templates" / "plan"
        template_dir.mkdir(parents=True, exist_ok=True)
        (template_dir / "default.md").write_text("# Plan Template")

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_ops.return_value = mock_git_instance

        # Mock template selection
        mock_select_template.return_value = "default"

        # Mock phase execution
        mock_phase_instance = MagicMock()
        mock_phase_instance.execute.return_value = PhaseResult(
            status=PhaseStatus.COMPLETED,
            message="Plan completed",
            data={"iterations": 2}
        )
        mock_plan_phase.return_value = mock_phase_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "--no-interactive", "--template", "default"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 0
        assert "Implementation plan completed" in result.stdout
        assert "Iterations: 2" in result.stdout
        mock_plan_phase.assert_called_once()

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli.select_template")
    @patch("cafe.ui.cli.PlanPhase")
    def test_plan_github_mode_with_issue(
        self,
        mock_plan_phase: Mock,
        mock_select_template: Mock,
        mock_git_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 plan 指令使用 issue ID"""
        # Setup: Create config.yaml
        _create_minimal_config(tmp_path)

        # Setup: Checks if versioned spec file exists first (new structure)
        branch_name = "test-issue"
        spec_dir = tmp_path / ".cafe" / "issues" / branch_name / "spec"
        iter_dir = spec_dir / "iteration_001"
        iter_dir.mkdir(parents=True, exist_ok=True)
        spec_file = iter_dir / "output.md"
        spec_file.write_text("# Spec\n\n## 開發指南\nGuide")

        # Create a default template
        template_dir = tmp_path / ".cafe" / "templates" / "plan"
        template_dir.mkdir(parents=True, exist_ok=True)
        (template_dir / "default.md").write_text("# Plan Template")

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_ops.return_value = mock_git_instance

        # Mock template selection
        mock_select_template.return_value = "default"

        # Mock phase execution
        mock_phase_instance = MagicMock()
        mock_phase_instance.execute.return_value = PhaseResult(
            status=PhaseStatus.COMPLETED,
            message="Plan completed",
            data={"iterations": 1}
        )
        mock_plan_phase.return_value = mock_phase_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "-i", "123", "--no-interactive", "--template", "default"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 0
        assert "GitHub Issue: #123" in result.stdout
        mock_plan_phase.assert_called_once()

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli.select_template")
    @patch("cafe.ui.cli.PlanPhase")
    def test_plan_fails_with_error(
        self,
        mock_plan_phase: Mock,
        mock_select_template: Mock,
        mock_git_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 plan 指令執行失敗"""
        # Setup: Create config.yaml
        _create_minimal_config(tmp_path)

        # Setup: Create versioned spec file in the expected location (new structure)
        branch_name = "test-issue"
        spec_dir = tmp_path / ".cafe" / "issues" / branch_name / "spec"
        iter_dir = spec_dir / "iteration_001"
        iter_dir.mkdir(parents=True, exist_ok=True)
        spec_file = iter_dir / "output.md"
        spec_file.write_text("# Spec")

        # Create a default template
        template_dir = tmp_path / ".cafe" / "templates" / "plan"
        template_dir.mkdir(parents=True, exist_ok=True)
        (template_dir / "default.md").write_text("# Plan Template")

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_ops.return_value = mock_git_instance

        # Mock template selection
        mock_select_template.return_value = "default"

        # Mock phase execution failure
        mock_phase_instance = MagicMock()
        mock_phase_instance.execute.return_value = PhaseResult(
            status=PhaseStatus.FAILED,
            message="Missing dev guide"
        )
        mock_plan_phase.return_value = mock_phase_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "--no-interactive", "--template", "default"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "Plan phase failed" in result.stdout


    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli.select_template")
    @patch("cafe.ui.cli.PlanPhase")
    def test_plan_loads_template_from_issue_config(
        self,
        mock_plan_phase: Mock,
        mock_select_template: Mock,
        mock_git_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 plan 指令從 issue.yaml 載入 template 設定，不應該提示選擇"""
        # Setup: Create config.yaml
        _create_minimal_config(tmp_path)

        # Setup: Create versioned spec file (new structure)
        branch_name = "test-issue"
        spec_dir = tmp_path / ".cafe" / "issues" / branch_name / "spec"
        iter_dir = spec_dir / "iteration_001"
        iter_dir.mkdir(parents=True, exist_ok=True)
        spec_file = iter_dir / "output.md"
        spec_file.write_text("# Spec\n\n## 開發指南\nGuide")

        # Setup: Create issue.yaml with plan.template: auto
        issue_config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        issue_config_file.write_text("plan:\n  template: auto\n")

        # Create a default template
        template_dir = tmp_path / ".cafe" / "templates" / "plan"
        template_dir.mkdir(parents=True, exist_ok=True)
        (template_dir / "default.md").write_text("# Plan Template")

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_ops.return_value = mock_git_instance

        # Mock phase execution
        mock_phase_instance = MagicMock()
        mock_phase_instance.execute.return_value = PhaseResult(
            status=PhaseStatus.COMPLETED,
            message="Plan completed",
            data={"iterations": 1, "status_code": "CAFE_CONFIRMED"}
        )
        mock_plan_phase.return_value = mock_phase_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "--no-interactive"])
        finally:
            os.chdir(old_cwd)

        # Verify: Should not call select_template because config has template setting
        mock_select_template.assert_not_called()

        # Verify: PlanPhase should be called with template_mode="auto"
        mock_plan_phase.assert_called_once()
        call_kwargs = mock_plan_phase.call_args[1]
        assert call_kwargs["template_mode"] == "auto"
        assert call_kwargs["template_path"] is None

        # Verify: Command succeeded
        assert result.exit_code == 0
        # Verify auto mode is indicated in output
        assert "Template mode: auto" in result.stdout

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli.select_template")
    @patch("cafe.ui.cli.PlanPhase")
    @patch("sys.stdin.isatty")
    def test_plan_loads_template_from_issue_config_interactive(
        self,
        mock_isatty: Mock,
        mock_plan_phase: Mock,
        mock_select_template: Mock,
        mock_git_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試互動模式下，plan 指令從 issue.yaml 載入 template 設定，不應該提示選擇"""
        # Setup: Create config.yaml
        _create_minimal_config(tmp_path)

        # Setup: Create versioned spec file (new structure)
        branch_name = "test-issue"
        spec_dir = tmp_path / ".cafe" / "issues" / branch_name / "spec"
        iter_dir = spec_dir / "iteration_001"
        iter_dir.mkdir(parents=True, exist_ok=True)
        spec_file = iter_dir / "output.md"
        spec_file.write_text("# Spec\n\n## 開發指南\nGuide")

        # Setup: Create issue.yaml with plan.template: auto
        issue_config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        issue_config_file.write_text("plan:\n  template: auto\n")

        # Create a default template
        template_dir = tmp_path / ".cafe" / "templates" / "plan"
        template_dir.mkdir(parents=True, exist_ok=True)
        (template_dir / "default.md").write_text("# Plan Template")

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_ops.return_value = mock_git_instance

        # Mock isatty to simulate interactive mode
        mock_isatty.return_value = True

        # Mock phase execution
        mock_phase_instance = MagicMock()
        mock_phase_instance.execute.return_value = PhaseResult(
            status=PhaseStatus.COMPLETED,
            message="Plan completed",
            data={"iterations": 1, "status_code": "CAFE_CONFIRMED"}
        )
        mock_plan_phase.return_value = mock_phase_instance

        # Execute in interactive mode (default)
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan"])
        finally:
            os.chdir(old_cwd)

        # Verify: Should not call select_template because config has template setting
        mock_select_template.assert_not_called()

        # Verify: PlanPhase should be called with template_mode="auto"
        mock_plan_phase.assert_called_once()
        call_kwargs = mock_plan_phase.call_args[1]
        assert call_kwargs["template_mode"] == "auto"
        assert call_kwargs["template_path"] is None

        # Verify: Command succeeded
        assert result.exit_code == 0


class TestCloseCommand:
    """Test close command."""

    @patch("cafe.ui.cli.GitHubOps")
    @patch("cafe.ui.cli.GitOperations")
    def test_close_normal_mode_success(
        self,
        mock_git_ops: Mock,
        mock_github_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 close 指令在一般模式（非 worktree）下成功執行"""
        # Setup: Create issue config
        branch_name = "test-issue"
        config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        config_file.parent.mkdir(parents=True, exist_ok=True)
        config_file.write_text("""
base_branch: main
feature_branch: test-issue
""")

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_instance.checkout_branch.return_value = None
        mock_git_instance.pull.return_value = None
        mock_git_instance.delete_branch.return_value = None
        mock_git_ops.return_value = mock_git_instance

        # Mock GitHub operations (no open PR)
        mock_github_instance = MagicMock()
        mock_github_instance.get_pr_for_branch.return_value = None
        mock_github_ops.return_value = mock_github_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["close"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 0
        assert "Successfully closed issue" in result.stdout

        # Verify all operations were called
        mock_git_instance.checkout_branch.assert_called_once_with("main")
        mock_git_instance.pull.assert_called_once()
        mock_git_instance.delete_branch.assert_called_once_with("test-issue")

    @patch("cafe.ui.cli.GitHubOps")
    @patch("cafe.ui.cli.GitOperations")
    def test_close_worktree_mode_success(
        self,
        mock_git_ops: Mock,
        mock_github_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 close 指令在 worktree 模式下成功執行"""
        # Setup: Create issue config with worktree_path
        branch_name = "test-issue"
        worktree_path = ".cafe/worktrees/test-issue"
        config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        config_file.parent.mkdir(parents=True, exist_ok=True)
        config_file.write_text(f"""
base_branch: main
feature_branch: test-issue
worktree_path: {worktree_path}
""")

        # Create .git directory to simulate main repo
        git_dir = tmp_path / ".git"
        git_dir.mkdir(parents=True, exist_ok=True)

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_instance.checkout_branch.return_value = None
        mock_git_instance.pull.return_value = None
        mock_git_instance.remove_worktree.return_value = None
        mock_git_instance.delete_branch.return_value = None
        mock_git_ops.return_value = mock_git_instance

        # Mock GitHub operations (no open PR)
        mock_github_instance = MagicMock()
        mock_github_instance.get_pr_for_branch.return_value = None
        mock_github_ops.return_value = mock_github_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["close"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 0
        assert "Successfully closed issue" in result.stdout

        # Verify all operations were called in worktree mode
        mock_git_instance.checkout_branch.assert_called_once_with("main")
        mock_git_instance.pull.assert_called_once()
        mock_git_instance.remove_worktree.assert_called_once_with(worktree_path)
        mock_git_instance.delete_branch.assert_called_once_with("test-issue")

    @patch("cafe.ui.cli.GitHubOps")
    @patch("cafe.ui.cli.GitOperations")
    def test_close_fails_on_checkout_error_normal_mode(
        self,
        mock_git_ops: Mock,
        mock_github_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 close 在 checkout 失敗時中斷（一般模式）"""
        # Setup
        branch_name = "test-issue"
        config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        config_file.parent.mkdir(parents=True, exist_ok=True)
        config_file.write_text("base_branch: main\nfeature_branch: test-issue\n")

        # Mock Git operations - checkout fails
        mock_git_instance = MagicMock()
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_instance.checkout_branch.side_effect = Exception("Uncommitted changes")
        mock_git_ops.return_value = mock_git_instance

        # Mock GitHub
        mock_github_instance = MagicMock()
        mock_github_instance.get_pr_for_branch.return_value = None
        mock_github_ops.return_value = mock_github_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["close"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "Failed to switch to base branch" in result.stdout
        assert "Remaining steps" in result.stdout
        assert "git checkout main" in result.stdout
        assert "git pull" in result.stdout
        assert "git branch -d test-issue" in result.stdout

        # Verify subsequent operations were not called
        mock_git_instance.pull.assert_not_called()
        mock_git_instance.delete_branch.assert_not_called()

    @patch("cafe.ui.cli.GitHubOps")
    @patch("cafe.ui.cli.GitOperations")
    def test_close_fails_on_pull_error_normal_mode(
        self,
        mock_git_ops: Mock,
        mock_github_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 close 在 pull 失敗時中斷（一般模式）"""
        # Setup
        branch_name = "test-issue"
        config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        config_file.parent.mkdir(parents=True, exist_ok=True)
        config_file.write_text("base_branch: main\nfeature_branch: test-issue\n")

        # Mock Git operations - pull fails
        mock_git_instance = MagicMock()
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_instance.checkout_branch.return_value = None
        mock_git_instance.pull.side_effect = Exception("Network error")
        mock_git_ops.return_value = mock_git_instance

        # Mock GitHub
        mock_github_instance = MagicMock()
        mock_github_instance.get_pr_for_branch.return_value = None
        mock_github_ops.return_value = mock_github_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["close"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "Failed to update base branch" in result.stdout
        assert "Remaining steps" in result.stdout
        assert "git pull" in result.stdout
        assert "git branch -d test-issue" in result.stdout

        # Verify delete was not called
        mock_git_instance.delete_branch.assert_not_called()

    @patch("cafe.ui.cli.GitHubOps")
    @patch("cafe.ui.cli.GitOperations")
    def test_close_fails_on_delete_branch_error(
        self,
        mock_git_ops: Mock,
        mock_github_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 close 在刪除分支失敗時顯示錯誤"""
        # Setup
        branch_name = "test-issue"
        config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        config_file.parent.mkdir(parents=True, exist_ok=True)
        config_file.write_text("base_branch: main\nfeature_branch: test-issue\n")

        # Mock Git operations - delete fails
        mock_git_instance = MagicMock()
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_instance.checkout_branch.return_value = None
        mock_git_instance.pull.return_value = None
        mock_git_instance.delete_branch.side_effect = Exception("Branch not fully merged")
        mock_git_ops.return_value = mock_git_instance

        # Mock GitHub
        mock_github_instance = MagicMock()
        mock_github_instance.get_pr_for_branch.return_value = None
        mock_github_ops.return_value = mock_github_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["close"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "Failed to delete branch" in result.stdout
        assert "Remaining steps" in result.stdout
        assert "git branch -D test-issue" in result.stdout

    @patch("cafe.ui.cli.GitHubOps")
    @patch("cafe.ui.cli.GitOperations")
    def test_close_fails_on_remove_worktree_error(
        self,
        mock_git_ops: Mock,
        mock_github_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 close 在刪除 worktree 失敗時中斷（worktree 模式）"""
        # Setup
        branch_name = "test-issue"
        worktree_path = ".cafe/worktrees/test-issue"
        config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        config_file.parent.mkdir(parents=True, exist_ok=True)
        config_file.write_text(f"""
base_branch: main
feature_branch: test-issue
worktree_path: {worktree_path}
""")

        # Create .git directory
        git_dir = tmp_path / ".git"
        git_dir.mkdir(parents=True, exist_ok=True)

        # Mock Git operations - remove_worktree fails
        mock_git_instance = MagicMock()
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_instance.checkout_branch.return_value = None
        mock_git_instance.pull.return_value = None
        mock_git_instance.remove_worktree.side_effect = Exception("Worktree has changes")
        mock_git_ops.return_value = mock_git_instance

        # Mock GitHub
        mock_github_instance = MagicMock()
        mock_github_instance.get_pr_for_branch.return_value = None
        mock_github_ops.return_value = mock_github_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["close"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "Failed to remove worktree" in result.stdout
        assert "Remaining steps" in result.stdout
        assert f"git worktree remove {worktree_path}" in result.stdout
        assert "git branch -d test-issue" in result.stdout

        # Verify delete_branch was not called
        mock_git_instance.delete_branch.assert_not_called()

    @patch("cafe.ui.cli.GitHubOps")
    @patch("cafe.ui.cli.GitOperations")
    def test_close_blocks_on_open_pr(
        self,
        mock_git_ops: Mock,
        mock_github_ops: Mock,
        tmp_path: Path,
    ) -> None:
        """測試 close 在有 open PR 時被阻擋"""
        # Setup
        branch_name = "test-issue"
        config_file = tmp_path / ".cafe" / "issues" / branch_name / "issue.yaml"
        config_file.parent.mkdir(parents=True, exist_ok=True)
        config_file.write_text("base_branch: main\n")

        # Mock Git operations
        mock_git_instance = MagicMock()
        mock_git_instance.get_current_branch.return_value = branch_name
        mock_git_ops.return_value = mock_git_instance

        # Mock GitHub - has open PR
        mock_github_instance = MagicMock()
        mock_github_instance.get_pr_for_branch.return_value = {
            "number": 123,
            "title": "Test PR",
            "state": "OPEN",
            "isDraft": False,
            "url": "https://github.com/user/repo/pull/123"
        }
        mock_github_ops.return_value = mock_github_instance

        # Execute
        import os
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["close"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "Cannot close: Open PR found" in result.stdout
        assert "PR #123" in result.stdout

        # Verify no git operations were performed
        mock_git_instance.checkout_branch.assert_not_called()
        mock_git_instance.delete_branch.assert_not_called()


class TestEditFileWithEditor:
    """測試 _edit_file_with_editor 函式"""

    @patch("cafe.ui.cli.subprocess.run")
    @patch.dict("os.environ", {"EDITOR": "nano"})
    def test_edit_file_with_custom_editor(
        self, mock_subprocess: Mock, tmp_path: Path
    ) -> None:
        """測試使用 $EDITOR 環境變數開啟檔案"""
        from cafe.ui.cli import _edit_file_with_editor

        # Setup
        test_file = tmp_path / "test.md"
        test_file.write_text("test content")
        mock_subprocess.return_value = Mock(returncode=0)

        # Execute
        _edit_file_with_editor(test_file)

        # Verify
        mock_subprocess.assert_called_once_with(["nano", str(test_file)], check=True)

    @patch("cafe.ui.cli.subprocess.run")
    @patch.dict("os.environ", {}, clear=True)
    def test_edit_file_with_default_vim(
        self, mock_subprocess: Mock, tmp_path: Path
    ) -> None:
        """測試預設使用 vim 作為編輯器"""
        from cafe.ui.cli import _edit_file_with_editor

        # Setup
        test_file = tmp_path / "test.md"
        test_file.write_text("test content")
        mock_subprocess.return_value = Mock(returncode=0)

        # Execute
        _edit_file_with_editor(test_file)

        # Verify
        mock_subprocess.assert_called_once_with(["vim", str(test_file)], check=True)

    @patch("cafe.ui.cli.subprocess.run")
    @patch.dict("os.environ", {"EDITOR": "nonexistent-editor"})
    def test_edit_file_editor_not_found(
        self, mock_subprocess: Mock, tmp_path: Path
    ) -> None:
        """測試編輯器不存在時錯誤處理"""
        from cafe.ui.cli import _edit_file_with_editor

        # Setup
        test_file = tmp_path / "test.md"
        test_file.write_text("test content")
        mock_subprocess.side_effect = FileNotFoundError()

        # Execute & Verify
        with pytest.raises(typer.Exit) as exc_info:
            _edit_file_with_editor(test_file)
        assert exc_info.value.exit_code == 1

    @patch("cafe.ui.cli.subprocess.run")
    @patch.dict("os.environ", {"EDITOR": "vim"})
    def test_edit_file_editor_execution_failed(
        self, mock_subprocess: Mock, tmp_path: Path
    ) -> None:
        """測試編輯器執行失敗時錯誤處理"""
        from cafe.ui.cli import _edit_file_with_editor
        import subprocess

        # Setup
        test_file = tmp_path / "test.md"
        test_file.write_text("test content")
        mock_subprocess.side_effect = subprocess.CalledProcessError(1, "vim")

        # Execute & Verify
        with pytest.raises(typer.Exit) as exc_info:
            _edit_file_with_editor(test_file)
        assert exc_info.value.exit_code == 1


class TestSpecEditCommand:
    """測試 cafe spec edit 指令"""

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli._edit_file_with_editor")
    def test_spec_edit_opens_latest_file(
        self, mock_edit: Mock, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試正確找到並開啟最新 spec_XXX.md 檔案"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create issue directory and spec files in new structure
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        spec_dir = issue_dir / "spec"
        iter1_dir = spec_dir / "iteration_001"
        iter2_dir = spec_dir / "iteration_002"
        iter1_dir.mkdir(parents=True)
        iter2_dir.mkdir(parents=True)
        spec_file_1 = iter1_dir / "output.md"
        spec_file_2 = iter2_dir / "output.md"
        spec_file_1.write_text("Spec version 1")
        spec_file_2.write_text("Spec version 2")

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["spec", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 0
        mock_edit.assert_called_once()
        called_path = mock_edit.call_args[0][0]
        assert called_path.name == "output.md"
        assert called_path.parent.name == "iteration_002"

    @patch("cafe.ui.cli.GitOperations")
    def test_spec_edit_no_file_shows_error(
        self, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試沒有 spec 檔案時顯示錯誤"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create issue directory but no spec files
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        spec_dir = issue_dir / "spec"
        spec_dir.mkdir(parents=True)

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["spec", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "No spec file found" in result.stdout
        assert "cafe spec" in result.stdout

    @patch("cafe.ui.cli.GitOperations")
    def test_spec_edit_not_in_issue_branch_shows_error(
        self, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試不在 issue branch 上時顯示錯誤"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Don't create .cafe directory to simulate not initialized
        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["spec", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "not been initialized" in result.stdout
        assert "cafe prepare" in result.stdout

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli._edit_file_with_editor")
    def test_spec_edit_shows_success_message(
        self, mock_edit: Mock, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試編輯完成後顯示成功訊息"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create spec file in new structure
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        spec_dir = issue_dir / "spec"
        iter_dir = spec_dir / "iteration_001"
        iter_dir.mkdir(parents=True)
        spec_file = iter_dir / "output.md"
        spec_file.write_text("Spec content")

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["spec", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify - message from _edit_file_with_editor
        assert result.exit_code == 0
        mock_edit.assert_called_once()


class TestPlanEditCommand:
    """測試 cafe plan edit 指令"""

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli._edit_file_with_editor")
    def test_plan_edit_opens_latest_file(
        self, mock_edit: Mock, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試正確找到並開啟最新 plan_XXX.md 檔案"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create issue directory and plan files in new structure
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        plan_dir = issue_dir / "plan"
        iter1_dir = plan_dir / "iteration_001"
        iter2_dir = plan_dir / "iteration_002"
        iter1_dir.mkdir(parents=True)
        iter2_dir.mkdir(parents=True)
        plan_file_1 = iter1_dir / "output.md"
        plan_file_2 = iter2_dir / "output.md"
        plan_file_1.write_text("Plan version 1")
        plan_file_2.write_text("Plan version 2")

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 0
        mock_edit.assert_called_once()
        called_path = mock_edit.call_args[0][0]
        assert called_path.name == "output.md"
        assert called_path.parent.name == "iteration_002"

    @patch("cafe.ui.cli.GitOperations")
    def test_plan_edit_no_file_shows_error(
        self, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試沒有 plan 檔案時顯示錯誤"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create issue directory but no plan files
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        plan_dir = issue_dir / "plan"
        plan_dir.mkdir(parents=True)

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "No plan file found" in result.stdout
        assert "cafe plan" in result.stdout

    @patch("cafe.ui.cli.GitOperations")
    def test_plan_edit_not_in_issue_branch_shows_error(
        self, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試不在 issue branch 上時顯示錯誤"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Don't create .cafe directory to simulate not initialized
        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "not been initialized" in result.stdout
        assert "cafe prepare" in result.stdout

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli._edit_file_with_editor")
    def test_plan_edit_shows_success_message(
        self, mock_edit: Mock, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試編輯完成後顯示成功訊息"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create plan file in new structure
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        plan_dir = issue_dir / "plan"
        iter_dir = plan_dir / "iteration_001"
        iter_dir.mkdir(parents=True)
        plan_file = iter_dir / "output.md"
        plan_file.write_text("Plan content")

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["plan", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify - message from _edit_file_with_editor
        assert result.exit_code == 0
        mock_edit.assert_called_once()


class TestReviewEditCommand:
    """測試 cafe review edit 指令"""

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli._edit_file_with_editor")
    def test_review_edit_opens_latest_file(
        self, mock_edit: Mock, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試正確找到並開啟最新 review_XXX.md 檔案"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create issue directory and review files in new structure
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        review_dir = issue_dir / "review"
        iter1_dir = review_dir / "iteration_001"
        iter2_dir = review_dir / "iteration_002"
        iter1_dir.mkdir(parents=True)
        iter2_dir.mkdir(parents=True)
        review_file_1 = iter1_dir / "output.md"
        review_file_2 = iter2_dir / "output.md"
        review_file_1.write_text("Review version 1")
        review_file_2.write_text("Review version 2")

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["review", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 0
        mock_edit.assert_called_once()
        called_path = mock_edit.call_args[0][0]
        assert called_path.name == "output.md"
        assert called_path.parent.name == "iteration_002"

    @patch("cafe.ui.cli.GitOperations")
    def test_review_edit_no_file_shows_error(
        self, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試沒有 review 檔案時顯示錯誤"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create issue directory but no review files
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        review_dir = issue_dir / "review"
        review_dir.mkdir(parents=True)

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["review", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "No review file found" in result.stdout
        assert "cafe review" in result.stdout

    @patch("cafe.ui.cli.GitOperations")
    def test_review_edit_not_in_issue_branch_shows_error(
        self, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試不在 issue branch 上時顯示錯誤"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Don't create .cafe directory to simulate not initialized
        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["review", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify
        assert result.exit_code == 1
        assert "not been initialized" in result.stdout
        assert "cafe prepare" in result.stdout

    @patch("cafe.ui.cli.GitOperations")
    @patch("cafe.ui.cli._edit_file_with_editor")
    def test_review_edit_shows_success_message(
        self, mock_edit: Mock, mock_git_ops_class: Mock, tmp_path: Path
    ) -> None:
        """測試編輯完成後顯示成功訊息"""
        import os

        # Setup
        mock_git_instance = MagicMock()
        mock_git_instance.is_valid_branch.return_value = True
        mock_git_instance.get_current_branch.return_value = "test-issue"
        mock_git_ops_class.return_value = mock_git_instance

        # Create review file in new structure
        issue_dir = tmp_path / ".cafe" / "issues" / "test-issue"
        review_dir = issue_dir / "review"
        iter_dir = review_dir / "iteration_001"
        iter_dir.mkdir(parents=True)
        review_file = iter_dir / "output.md"
        review_file.write_text("Review content")

        # Execute
        old_cwd = os.getcwd()
        try:
            os.chdir(tmp_path)
            result = runner.invoke(app, ["review", "edit"])
        finally:
            os.chdir(old_cwd)

        # Verify - message from _edit_file_with_editor
        assert result.exit_code == 0
        mock_edit.assert_called_once()
