"""Tests for ievo.tui.app — TUI dashboard."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch

import yaml as _yaml

from ievo.core.project import ProjectManifest
from ievo.core.registry import Registry
from ievo.tui.app import LOGO, MODEL_COLORS, IevoApp, run_tui

# ── Constants ────────────────────────────────────────────────


def test_logo_is_string() -> None:
    assert isinstance(LOGO, str)
    assert "iEvo" in LOGO or "██" in LOGO


def test_model_colors() -> None:
    assert MODEL_COLORS["opus"] == "#f87171"
    assert MODEL_COLORS["sonnet"] == "#a78bfa"
    assert MODEL_COLORS["haiku"] == "#22d3ee"


# ── IevoApp class ────────────────────────────────────────────


def test_ievo_app_creation() -> None:
    """IevoApp can be instantiated."""
    app = IevoApp()
    assert app.TITLE == "iEvo"
    assert app.SUB_TITLE == "Self-evolving AI agent framework"


def test_ievo_app_bindings() -> None:
    """Verify expected key bindings exist."""
    app = IevoApp()
    binding_keys = {b.key for b in app.BINDINGS}
    assert "q" in binding_keys
    assert "r" in binding_keys
    assert "a" in binding_keys


def test_ievo_app_css_exists() -> None:
    """CSS is not empty."""
    assert IevoApp.CSS
    assert "Screen" in IevoApp.CSS


# ── run_tui ──────────────────────────────────────────────────


def test_run_tui_calls_app_run() -> None:
    """run_tui creates an IevoApp and calls run()."""
    with patch("ievo.tui.app.IevoApp") as mock_cls:
        mock_app = MagicMock()
        mock_cls.return_value = mock_app
        run_tui()
        mock_app.run.assert_called_once()


# ── _load_agents (SelectionList) ─────────────────────────────


def test_load_agents_shows_all_bundled(tmp_path: Path) -> None:
    """All bundled agents appear in the selection list."""
    from ievo.marketplace import list_bundled_agents

    _setup_project(tmp_path)

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    mock_list.clear_options.assert_called_once()
    assert mock_list.add_option.call_count == len(list_bundled_agents())


def test_load_agents_checks_installed(tmp_path: Path) -> None:
    """Installed agents have initial_state=True."""
    _setup_project(tmp_path)

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    # Find the spec-writer call
    for call in mock_list.add_option.call_args_list:
        selection = call[0][0]
        if selection.value == "spec-writer":
            assert selection.initial_state is True
            break
    else:
        raise AssertionError("spec-writer not found in selection list")


def test_load_agents_unchecked_for_not_installed(tmp_path: Path) -> None:
    """Non-installed agents have initial_state=False."""
    _setup_project(tmp_path)

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    # Find an agent that is NOT installed (e.g. architect)
    for call in mock_list.add_option.call_args_list:
        selection = call[0][0]
        if selection.value == "architect":
            assert selection.initial_state is False
            break
    else:
        raise AssertionError("architect not found in selection list")


def test_load_agents_no_registry(tmp_path: Path) -> None:
    """_load_agents returns early if no registry."""
    _setup_project(tmp_path)

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = None

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    mock_list.clear_options.assert_called_once()
    mock_list.add_option.assert_not_called()


# ── Toggle handler ───────────────────────────────────────────


def test_install_agent(tmp_path: Path) -> None:
    """_install_agent copies .md and updates manifest."""
    _setup_project(tmp_path)
    (tmp_path / ".claude" / "agents").mkdir(parents=True, exist_ok=True)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)

    registry = Registry.load_cached()
    entry = registry.get("architect")
    app._install_agent(tmp_path, "architect", entry)

    assert (tmp_path / ".claude" / "agents" / "architect.md").exists()
    manifest = ProjectManifest.load(tmp_path)
    assert manifest.has_agent("architect")


def test_install_agent_creates_dir(tmp_path: Path) -> None:
    """_install_agent creates .claude/agents/ if missing."""
    _setup_project(tmp_path)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)

    app._install_agent(tmp_path, "coder", None)

    assert (tmp_path / ".claude" / "agents" / "coder.md").exists()


def test_install_agent_placeholder(tmp_path: Path) -> None:
    """When bundled source is missing, a placeholder .md is created."""
    _setup_project(tmp_path)
    (tmp_path / ".claude" / "agents").mkdir(parents=True, exist_ok=True)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)

    with patch("ievo.tui.app.bundled_agent_path", return_value=None):
        app._install_agent(tmp_path, "custom-agent", None)

    md = tmp_path / ".claude" / "agents" / "custom-agent.md"
    assert md.exists()
    content = md.read_text()
    assert "custom-agent" in content
    assert "Placeholder" in content


def test_uninstall_agent(tmp_path: Path) -> None:
    """_uninstall_agent deletes .md and updates manifest."""
    _setup_project(tmp_path)
    claude_dir = tmp_path / ".claude" / "agents"
    claude_dir.mkdir(parents=True, exist_ok=True)
    (claude_dir / "spec-writer.md").write_text("---\nname: spec-writer\n---\n")

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)

    app._uninstall_agent(tmp_path, "spec-writer")

    assert not (claude_dir / "spec-writer.md").exists()
    manifest = ProjectManifest.load(tmp_path)
    assert not manifest.has_agent("spec-writer")


def test_uninstall_agent_missing_file(tmp_path: Path) -> None:
    """_uninstall_agent works even if .md already deleted."""
    _setup_project(tmp_path)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)

    app._uninstall_agent(tmp_path, "spec-writer")

    manifest = ProjectManifest.load(tmp_path)
    assert not manifest.has_agent("spec-writer")


def test_toggle_without_project_is_noop() -> None:
    """Toggle handler returns early if no project root."""
    app = IevoApp()
    app._manifest = None
    app._agent_registry = None

    event = MagicMock()
    event.selection.value = "spec-writer"

    with patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=None):
        app.on_selection_list_selection_toggled(event)
    # Should not crash


def test_toggle_on_calls_install(tmp_path: Path) -> None:
    """Toggling ON calls _install_agent."""
    _setup_project(tmp_path, agents={})
    (tmp_path / ".claude" / "agents").mkdir(parents=True, exist_ok=True)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)
    app._agent_registry = Registry.load_cached()

    mock_list = MagicMock()
    mock_list.selected = ["architect"]  # architect is now selected
    app.query_one = MagicMock(return_value=mock_list)

    mock_pipeline = MagicMock()
    app._load_pipeline = mock_pipeline

    event = MagicMock()
    event.selection.value = "architect"

    with (
        patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=tmp_path),
        patch.object(app, "notify"),
    ):
        app.on_selection_list_selection_toggled(event)

    assert (tmp_path / ".claude" / "agents" / "architect.md").exists()
    mock_pipeline.assert_called_once()


def test_toggle_off_calls_uninstall(tmp_path: Path) -> None:
    """Toggling OFF calls _uninstall_agent."""
    _setup_project(tmp_path)
    claude_dir = tmp_path / ".claude" / "agents"
    claude_dir.mkdir(parents=True, exist_ok=True)
    (claude_dir / "spec-writer.md").write_text("---\nname: spec-writer\n---\n")

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)
    app._agent_registry = Registry.load_cached()

    mock_list = MagicMock()
    mock_list.selected = []  # spec-writer is now deselected
    app.query_one = MagicMock(return_value=mock_list)

    mock_pipeline = MagicMock()
    app._load_pipeline = mock_pipeline

    event = MagicMock()
    event.selection.value = "spec-writer"

    with (
        patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=tmp_path),
        patch.object(app, "notify") as mock_notify,
    ):
        app.on_selection_list_selection_toggled(event)

    assert not (claude_dir / "spec-writer.md").exists()
    # spec-writer is mandatory → warning shown
    mock_notify.assert_any_call(
        "spec-writer is a mandatory agent. Removing it may break the SDD pipeline.",
        title="Warning",
        severity="warning",
        timeout=5,
    )
    mock_pipeline.assert_called_once()


def test_toggle_off_non_mandatory_no_warning(tmp_path: Path) -> None:
    """Toggling OFF a non-mandatory agent shows no warning."""
    _setup_project(tmp_path, agents={"custom-agent": "0.1.0"})

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)
    app._agent_registry = Registry.load_cached()

    mock_list = MagicMock()
    mock_list.selected = []
    app.query_one = MagicMock(return_value=mock_list)

    mock_pipeline = MagicMock()
    app._load_pipeline = mock_pipeline

    event = MagicMock()
    event.selection.value = "custom-agent"

    with patch.object(app, "notify") as mock_notify:
        app.on_selection_list_selection_toggled(event)

    # No warning — agent is not in registry (non-mandatory)
    for call in mock_notify.call_args_list:
        assert call.kwargs.get("severity") != "warning"


# ── _load_pipeline ───────────────────────────────────────────


def test_load_pipeline_with_agents(tmp_path: Path) -> None:
    """Pipeline shows flow diagram."""
    _setup_project(tmp_path)
    app = IevoApp()
    mock_static = MagicMock()
    app.query_one = MagicMock(return_value=mock_static)

    manifest = ProjectManifest.load(tmp_path)
    app._load_pipeline(manifest)

    mock_static.update.assert_called_once()
    content = mock_static.update.call_args[0][0]
    assert "SDD Pipeline" in content
    assert "spec-writer" in content


def test_load_pipeline_empty() -> None:
    """Empty manifest shows 'no agents installed'."""
    app = IevoApp()
    mock_static = MagicMock()
    app.query_one = MagicMock(return_value=mock_static)

    manifest = ProjectManifest(project="test")
    app._load_pipeline(manifest)

    mock_static.update.assert_called_once()
    content = mock_static.update.call_args[0][0]
    assert "No agents installed" in content


def test_load_pipeline_multiple_agents(tmp_path: Path) -> None:
    """Pipeline with multiple agents shows flow with arrows."""
    manifest_data = {
        "project": "test",
        "version": "0.1.0",
        "agents": {"spec-writer": "0.1.0", "coder": "0.1.0", "architect": "0.1.0"},
    }
    (tmp_path / ".ievo").mkdir()
    (tmp_path / ".ievo/manifest.yaml").write_text(_yaml.dump(manifest_data, sort_keys=False))

    app = IevoApp()
    mock_static = MagicMock()
    app.query_one = MagicMock(return_value=mock_static)

    manifest = ProjectManifest.load(tmp_path)
    app._load_pipeline(manifest)

    content = mock_static.update.call_args[0][0]
    assert "YOU" in content


# ── _load_evolution ──────────────────────────────────────────


def test_load_evolution_with_entries(tmp_path: Path) -> None:
    """Evolution log entries are displayed."""
    _setup_project(tmp_path)
    evo_log = tmp_path / "agents" / "spec-writer" / "EVOLUTION_LOG.md"
    evo_log.write_text("# Evolution Log\n\n## E-001: Fixed prompt\n\nDetails.\n")

    app = IevoApp()
    mock_log = MagicMock()
    app.query_one = MagicMock(return_value=mock_log)

    manifest = ProjectManifest.load(tmp_path)
    app._load_evolution(tmp_path, manifest)

    assert mock_log.write.call_count >= 1


def test_load_evolution_empty(tmp_path: Path) -> None:
    """No evolution entries shows placeholder."""
    _setup_project(tmp_path)

    app = IevoApp()
    mock_log = MagicMock()
    app.query_one = MagicMock(return_value=mock_log)

    manifest = ProjectManifest.load(tmp_path)
    app._load_evolution(tmp_path, manifest)

    calls = [str(c) for c in mock_log.write.call_args_list]
    assert any("No evolution" in c for c in calls)


def test_load_evolution_no_evo_file(tmp_path: Path) -> None:
    """Missing EVOLUTION_LOG.md shows placeholder."""
    manifest_data = {"project": "test", "version": "0.1.0", "agents": {"broken": "0.1.0"}}
    (tmp_path / ".ievo").mkdir()
    (tmp_path / ".ievo/manifest.yaml").write_text(_yaml.dump(manifest_data, sort_keys=False))
    (tmp_path / "agents" / "broken").mkdir(parents=True)

    app = IevoApp()
    mock_log = MagicMock()
    app.query_one = MagicMock(return_value=mock_log)

    manifest = ProjectManifest.load(tmp_path)
    app._load_evolution(tmp_path, manifest)


# ── _load_file_tree ──────────────────────────────────────────


def test_load_file_tree(tmp_path: Path) -> None:
    """File tree is populated from project directory."""
    (tmp_path / "src").mkdir()
    (tmp_path / "src" / "main.py").write_text("# main")
    (tmp_path / "agents").mkdir()

    app = IevoApp()
    mock_tree = MagicMock()
    mock_root = MagicMock()
    mock_tree.root = mock_root
    app.query_one = MagicMock(return_value=mock_tree)

    app._load_file_tree(tmp_path)

    mock_tree.clear.assert_called_once()
    mock_root.set_label.assert_called_once()
    mock_root.expand.assert_called_once()


# ── _add_path_to_tree ────────────────────────────────────────


def test_add_path_to_tree(tmp_path: Path) -> None:
    """Directories and files are added to tree node."""
    (tmp_path / "dir1").mkdir()
    (tmp_path / "dir1" / "file1.py").write_text("x")
    (tmp_path / "file2.txt").write_text("y")
    (tmp_path / ".hidden").write_text("z")  # should be skipped

    app = IevoApp()
    mock_node = MagicMock()
    mock_branch = MagicMock()
    mock_node.add.return_value = mock_branch

    app._add_path_to_tree(mock_node, tmp_path, depth=0, max_depth=2)

    mock_node.add.assert_called()  # directory
    mock_node.add_leaf.assert_called()  # file


def test_add_path_to_tree_max_depth(tmp_path: Path) -> None:
    """Stops at max_depth."""
    (tmp_path / "deep").mkdir()

    app = IevoApp()
    mock_node = MagicMock()

    app._add_path_to_tree(mock_node, tmp_path, depth=3, max_depth=3)

    mock_node.add.assert_not_called()
    mock_node.add_leaf.assert_not_called()


def test_add_path_to_tree_permission_error(tmp_path: Path) -> None:
    """PermissionError is handled gracefully."""
    app = IevoApp()
    mock_node = MagicMock()

    with patch("pathlib.Path.iterdir", side_effect=PermissionError):
        app._add_path_to_tree(mock_node, tmp_path, depth=0, max_depth=2)

    mock_node.add.assert_not_called()


# ── watch_project_root ───────────────────────────────────────


def test_watch_project_root_none() -> None:
    """None root shows 'no project' message."""
    app = IevoApp()
    mock_info = MagicMock()
    app.query_one = MagicMock(return_value=mock_info)

    app.watch_project_root(None)

    mock_info.update.assert_called_once()
    assert "No project" in mock_info.update.call_args[0][0]
    assert app._manifest is None
    assert app._agent_registry is None


def test_watch_project_root_with_project(tmp_path: Path) -> None:
    """Valid root loads all data."""
    _setup_project(tmp_path)

    app = IevoApp()
    mock_widget = MagicMock()
    mock_widget.root = MagicMock()
    app.query_one = MagicMock(return_value=mock_widget)

    app.watch_project_root(tmp_path)

    assert app._manifest is not None
    assert app._agent_registry is not None
    assert app.query_one.call_count >= 5


# ── action methods ───────────────────────────────────────────


def test_action_tab() -> None:
    """action_tab sets active tab."""
    app = IevoApp()
    mock_tabs = MagicMock()
    app.query_one = MagicMock(return_value=mock_tabs)

    app.action_tab("pipeline")

    assert mock_tabs.active == "pipeline"


def test_action_refresh_data() -> None:
    """action_refresh_data calls find_project_root."""
    app = IevoApp()
    with (
        patch("ievo.tui.app.find_project_root", return_value=None) as mock_find,
        patch.object(app, "watch_project_root"),
    ):
        app.action_refresh_data()
        mock_find.assert_called_once()


# ── compose / on_mount (Textual lifecycle) ──────────────────


def test_compose_and_on_mount() -> None:
    """Full Textual lifecycle covers compose() and on_mount()."""
    import asyncio

    async def _run() -> None:
        with patch("ievo.tui.app.find_project_root", return_value=None):
            app = IevoApp()
            async with app.run_test(size=(120, 40)) as pilot:  # noqa: F841
                assert app.title == "iEvo"
                agents_list = app.query_one("#agents-list")
                assert agents_list is not None
                pipeline_view = app.query_one("#pipeline-view")
                assert pipeline_view is not None

    asyncio.run(_run())


# ── Helper ───────────────────────────────────────────────────


# ── compare_agent_versions integration in _load_agents ───────


def test_load_agents_shows_outdated_badge(tmp_path: Path) -> None:
    """_load_agents adds [outdated] badge when installed version differs."""
    _setup_project(tmp_path, agents={"spec-writer": "0.1.0"})  # registry has 0.2.0

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    # Find the spec-writer Selection and verify badge text
    spec_writer_prompt = None
    for call in mock_list.add_option.call_args_list:
        selection = call[0][0]
        if selection.value == "spec-writer":
            spec_writer_prompt = str(selection.prompt)
            break
    assert spec_writer_prompt is not None, "spec-writer not found"
    assert "outdated" in spec_writer_prompt.lower()


def test_load_agents_no_badge_when_up_to_date(tmp_path: Path) -> None:
    """_load_agents shows no outdated badge when version matches registry."""
    _setup_project(tmp_path, agents={"spec-writer": "0.2.0"})  # matches registry

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    for call in mock_list.add_option.call_args_list:
        selection = call[0][0]
        if selection.value == "spec-writer":
            prompt_text = str(selection.prompt)
            assert "outdated" not in prompt_text.lower()
            return
    raise AssertionError("spec-writer not found")


def test_load_agents_badge_includes_version_numbers(tmp_path: Path) -> None:
    """Outdated badge includes both installed and latest version numbers."""
    _setup_project(tmp_path, agents={"spec-writer": "0.1.0"})  # registry 0.2.0

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    for call in mock_list.add_option.call_args_list:
        selection = call[0][0]
        if selection.value == "spec-writer":
            prompt_text = str(selection.prompt)
            assert "0.1.0" in prompt_text
            assert "0.2.0" in prompt_text
            return
    raise AssertionError("spec-writer not found")


def test_load_agents_no_badge_for_uninstalled_agent(tmp_path: Path) -> None:
    """Agents not in manifest show no outdated badge regardless of registry version."""
    _setup_project(tmp_path, agents={})  # nothing installed

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    for call in mock_list.add_option.call_args_list:
        selection = call[0][0]
        prompt_text = str(selection.prompt)
        assert "outdated" not in prompt_text.lower()


def test_load_agents_stores_outdated_dict(tmp_path: Path) -> None:
    """_load_agents populates app._outdated with outdated agents."""
    _setup_project(tmp_path, agents={"spec-writer": "0.1.0"})

    app = IevoApp()
    mock_list = MagicMock()
    app.query_one = MagicMock(return_value=mock_list)
    app._agent_registry = Registry.load_cached()

    manifest = ProjectManifest.load(tmp_path)
    app._load_agents(tmp_path, manifest)

    assert "spec-writer" in app._outdated
    assert app._outdated["spec-writer"] == ("0.1.0", "0.2.0")


# ── watch_project_root toast ─────────────────────────────────


def test_watch_project_root_shows_outdated_toast(tmp_path: Path) -> None:
    """watch_project_root calls notify() with warning severity when agents outdated."""
    _setup_project(tmp_path, agents={"spec-writer": "0.1.0"})  # registry 0.2.0

    app = IevoApp()
    mock_widget = MagicMock()
    mock_widget.root = MagicMock()
    app.query_one = MagicMock(return_value=mock_widget)

    with patch.object(app, "notify") as mock_notify:
        app.watch_project_root(tmp_path)

    warning_calls = [
        call for call in mock_notify.call_args_list if call.kwargs.get("severity") == "warning"
    ]
    assert len(warning_calls) >= 1
    msg = warning_calls[0].args[0]
    assert "outdated" in msg.lower()


def test_watch_project_root_no_toast_when_all_up_to_date(tmp_path: Path) -> None:
    """watch_project_root does NOT call notify() with warning when all up to date."""
    _setup_project(tmp_path, agents={"spec-writer": "0.2.0"})  # matches registry

    app = IevoApp()
    mock_widget = MagicMock()
    mock_widget.root = MagicMock()
    app.query_one = MagicMock(return_value=mock_widget)

    with patch.object(app, "notify") as mock_notify:
        app.watch_project_root(tmp_path)

    warning_calls = [
        call for call in mock_notify.call_args_list if call.kwargs.get("severity") == "warning"
    ]
    assert len(warning_calls) == 0


def test_watch_project_root_no_toast_when_no_registry(tmp_path: Path) -> None:
    """No crash and no outdated toast when registry is None."""
    _setup_project(tmp_path, agents={"spec-writer": "0.1.0"})

    app = IevoApp()
    mock_widget = MagicMock()
    mock_widget.root = MagicMock()
    app.query_one = MagicMock(return_value=mock_widget)

    with (
        patch("ievo.tui.app.Registry") as mock_reg_cls,
        patch.object(app, "notify") as mock_notify,
    ):
        mock_reg_cls.load_cached.return_value = None
        app.watch_project_root(tmp_path)

    warning_calls = [
        call for call in mock_notify.call_args_list if call.kwargs.get("severity") == "warning"
    ]
    assert len(warning_calls) == 0


def test_watch_project_root_toast_message_format(tmp_path: Path) -> None:
    """Toast message matches 'N agent(s) outdated. Press u to update all.'"""
    _setup_project(tmp_path, agents={"spec-writer": "0.1.0"})  # 1 outdated

    app = IevoApp()
    mock_widget = MagicMock()
    mock_widget.root = MagicMock()
    app.query_one = MagicMock(return_value=mock_widget)

    with patch.object(app, "notify") as mock_notify:
        app.watch_project_root(tmp_path)

    warning_calls = [
        call for call in mock_notify.call_args_list if call.kwargs.get("severity") == "warning"
    ]
    assert len(warning_calls) == 1
    msg = warning_calls[0].args[0]
    assert "1 agent(s) outdated" in msg
    assert "Press u to update all" in msg


# ── Batch update action (u key) ──────────────────────────────


def test_binding_u_exists() -> None:
    """'u' key binding exists in IevoApp.BINDINGS."""
    app = IevoApp()
    binding_keys = {b.key for b in app.BINDINGS}
    assert "u" in binding_keys


def test_action_update_outdated_updates_agents(tmp_path: Path) -> None:
    """action_update_outdated calls _install_agent for each outdated agent."""
    _setup_project(tmp_path)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)
    app._agent_registry = Registry.load_cached()
    app._outdated = {"coder": ("0.1.0", "0.2.0")}

    mock_install = MagicMock()
    mock_load_agents = MagicMock()
    mock_load_pipeline = MagicMock()
    app._install_agent = mock_install
    app._load_agents = mock_load_agents
    app._load_pipeline = mock_load_pipeline

    with (
        patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=tmp_path),
        patch.object(app, "notify"),
    ):
        app.action_update_outdated()

    mock_install.assert_called_once()
    call_args = mock_install.call_args
    assert call_args[0][1] == "coder"  # second positional arg is agent name
    mock_load_agents.assert_called_once()


def test_action_update_outdated_shows_toast(tmp_path: Path) -> None:
    """action_update_outdated shows 'Updated N agent(s): names' notification."""
    _setup_project(tmp_path)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)
    app._agent_registry = Registry.load_cached()
    app._outdated = {"coder": ("0.1.0", "0.2.0"), "architect": ("0.1.0", "0.2.0")}

    app._install_agent = MagicMock()
    app._load_agents = MagicMock()
    app._load_pipeline = MagicMock()

    with (
        patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=tmp_path),
        patch.object(app, "notify") as mock_notify,
    ):
        app.action_update_outdated()

    info_calls = [
        call for call in mock_notify.call_args_list if call.kwargs.get("severity") == "information"
    ]
    assert len(info_calls) == 1
    msg = info_calls[0].args[0]
    assert "Updated 2 agent(s)" in msg
    assert "coder" in msg
    assert "architect" in msg


def test_action_update_outdated_no_outdated(tmp_path: Path) -> None:
    """action_update_outdated with empty _outdated shows 'All agents are up to date'."""
    _setup_project(tmp_path)

    app = IevoApp()
    app._manifest = ProjectManifest.load(tmp_path)
    app._agent_registry = Registry.load_cached()
    app._outdated = {}

    mock_install = MagicMock()
    app._install_agent = mock_install

    with (
        patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=tmp_path),
        patch.object(app, "notify") as mock_notify,
    ):
        app.action_update_outdated()

    mock_install.assert_not_called()
    calls_text = [str(c) for c in mock_notify.call_args_list]
    assert any("All agents are up to date" in t for t in calls_text)


def test_action_update_outdated_no_project() -> None:
    """action_update_outdated returns early without crash when project_root is None."""
    app = IevoApp()
    app._manifest = None
    app._agent_registry = None
    app._outdated = {}

    with patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=None):
        app.action_update_outdated()  # must not raise


# ── REQ-009: t keybinding and action_refresh_templates ───────


def test_binding_t_exists() -> None:
    """'t' key binding exists in IevoApp.BINDINGS."""
    app = IevoApp()
    binding_keys = {b.key for b in app.BINDINGS}
    assert "t" in binding_keys


def test_action_refresh_templates_no_project_noop() -> None:
    """action_refresh_templates is a no-op when project_root is None."""
    app = IevoApp()
    app._manifest = None

    with (
        patch.object(type(app), "project_root", new_callable=PropertyMock, return_value=None),
        patch.object(app, "notify") as mock_notify,
    ):
        app.action_refresh_templates()

    mock_notify.assert_not_called()


# ── Helper ───────────────────────────────────────────────────


def _setup_project(root: Path, agents: dict[str, str] | None = None) -> None:
    """Create a minimal project for TUI tests."""
    if agents is None:
        agents = {"spec-writer": "0.1.0"}

    manifest_data = {
        "project": "test-project",
        "version": "0.1.0",
        "agents": agents,
    }
    (root / ".ievo").mkdir(exist_ok=True)
    (root / ".ievo/manifest.yaml").write_text(_yaml.dump(manifest_data, sort_keys=False))

    for name in agents:
        agent_dir = root / "agents" / name
        agent_dir.mkdir(parents=True, exist_ok=True)
        agent_yaml = {
            "name": name,
            "description": f"Agent {name}",
            "model": {"primary": "sonnet"},
        }
        (agent_dir / "agent.yaml").write_text(_yaml.dump(agent_yaml, sort_keys=False))
        (agent_dir / "EVOLUTION_LOG.md").write_text("# Evolution Log\n\n")
