"""Tests for ErkDashApp using Textual Pilot."""

from collections.abc import Callable
from pathlib import Path

import pytest
from textual.widgets import Markdown

from erk.tui.app import (
    ErkDashApp,
    HelpScreen,
    IssueBodyScreen,
    PlanDetailScreen,
    _build_github_url,
)
from erk.tui.data.types import PlanFilters
from erk.tui.widgets.plan_table import PlanDataTable
from erk.tui.widgets.status_bar import StatusBar
from erk_shared.gateway.clipboard.fake import FakeClipboard
from erk_shared.gateway.plan_data_provider.fake import FakePlanDataProvider, make_plan_row


class TestErkDashAppCompose:
    """Tests for app composition and layout."""

    @pytest.mark.asyncio
    async def test_app_has_required_widgets(self) -> None:
        """App composes all required widgets."""
        provider = FakePlanDataProvider()
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test():
            # Check for PlanDataTable
            table = app.query_one(PlanDataTable)
            assert table is not None

            # Check for StatusBar
            status_bar = app.query_one(StatusBar)
            assert status_bar is not None


class TestErkDashAppDataLoading:
    """Tests for data loading behavior."""

    @pytest.mark.asyncio
    async def test_fetches_data_on_mount(self) -> None:
        """App fetches data when mounted."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(123, "Plan A"),
                make_plan_row(456, "Plan B"),
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            # Wait for async data load
            await pilot.pause()

            # Provider should have been called
            assert provider.fetch_count >= 1

    @pytest.mark.asyncio
    async def test_api_error_shows_notification_and_empty_table(self) -> None:
        """App shows error notification and empty table when API fails."""
        provider = FakePlanDataProvider(
            fetch_error="Network unreachable",
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            # Wait for async data load attempt
            await pilot.pause()
            await pilot.pause()

            # Provider should have been called
            assert provider.fetch_count >= 1

            # Table should be empty (no crash)
            assert len(app._rows) == 0

            # App should still be running (not crashed)
            # and table should be visible (meaning load completed, even with error)
            table = app.query_one(PlanDataTable)
            assert table.display is True


class TestErkDashAppNavigation:
    """Tests for keyboard navigation."""

    @pytest.mark.asyncio
    async def test_quit_on_q(self) -> None:
        """Pressing q quits the app."""
        provider = FakePlanDataProvider()
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.press("q")
            # App should have exited (no assertion needed - would hang if not)

    @pytest.mark.asyncio
    async def test_quit_on_escape(self) -> None:
        """Pressing escape quits the app."""
        provider = FakePlanDataProvider()
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.press("escape")
            # App should have exited

    @pytest.mark.asyncio
    async def test_help_on_question_mark(self) -> None:
        """Pressing ? shows help screen."""
        provider = FakePlanDataProvider()
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.press("?")
            # Wait for screen transition
            await pilot.pause()
            await pilot.pause()

            # Help screen should be in the screen stack
            assert len(app.screen_stack) > 1
            assert isinstance(app.screen_stack[-1], HelpScreen)


class TestErkDashAppRefresh:
    """Tests for data refresh behavior."""

    @pytest.mark.asyncio
    async def test_refresh_on_r(self) -> None:
        """Pressing r refreshes data."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Plan A")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            # Wait for initial load
            await pilot.pause()
            initial_count = provider.fetch_count

            # Press r to refresh
            await pilot.press("r")
            await pilot.pause()

            # Should have fetched again
            assert provider.fetch_count > initial_count


class TestStatusBar:
    """Tests for StatusBar widget."""

    def test_set_plan_count_singular(self) -> None:
        """Status bar shows singular 'plan' for count of 1."""
        bar = StatusBar()
        bar.set_plan_count(1)
        bar._update_display()
        # Check internal state was set
        assert bar._plan_count == 1

    def test_set_plan_count_plural(self) -> None:
        """Status bar shows plural 'plans' for count > 1."""
        bar = StatusBar()
        bar.set_plan_count(5)
        bar._update_display()
        assert bar._plan_count == 5

    def test_set_message(self) -> None:
        """Status bar can display a message."""
        bar = StatusBar()
        bar.set_message("Test message")
        bar._update_display()
        assert bar._message == "Test message"

    def test_clear_message(self) -> None:
        """Status bar can clear message."""
        bar = StatusBar()
        bar.set_message("Test message")
        bar.set_message(None)
        assert bar._message is None


class TestClosePlanViaCommandPalette:
    """Tests for close plan functionality via command palette.

    Note: The top-level 'c' binding was removed. Close plan is now accessible
    via the command palette in the plan detail modal (Space → Ctrl+P → "Close Plan").
    The execute_command tests in tests/tui/commands/test_execute_command.py
    cover the close_plan command execution. These tests verify the integration.
    """

    @pytest.mark.asyncio
    async def test_close_plan_not_accessible_via_c_key(self) -> None:
        """Top-level 'c' key should no longer close plans."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(123, "Feature A"),
                make_plan_row(456, "Feature B"),
            ],
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            # Wait for data to load
            await pilot.pause()
            await pilot.pause()

            # Initially should have 2 plans
            assert len(provider._plans) == 2

            # Press 'c' - should NOT close plan (binding removed)
            await pilot.press("c")
            await pilot.pause()
            await pilot.pause()

            # Plans should remain unchanged
            assert len(provider._plans) == 2


class TestFilterMode:
    """Tests for '/' filter mode functionality."""

    @pytest.mark.asyncio
    async def test_slash_activates_filter_mode(self) -> None:
        """Pressing '/' shows filter input and focuses it."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Plan A")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()

            # Press / to activate filter
            await pilot.press("slash")
            await pilot.pause()

            # Filter input should be visible and focused
            from textual.widgets import Input

            filter_input = app.query_one("#filter-input", Input)
            assert filter_input.has_class("visible")
            assert app.focused == filter_input

    @pytest.mark.asyncio
    async def test_filter_narrows_results(self) -> None:
        """Typing in filter input narrows displayed results."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(123, "Add user authentication"),
                make_plan_row(456, "Fix login bug"),
                make_plan_row(789, "Refactor database"),
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()

            # Verify all rows are displayed initially
            assert len(app._rows) == 3

            # Activate filter and type query
            await pilot.press("slash")
            await pilot.pause()
            await pilot.press("l", "o", "g", "i", "n")
            await pilot.pause()

            # Only matching row should be visible
            assert len(app._rows) == 1
            assert app._rows[0].issue_number == 456

    @pytest.mark.asyncio
    async def test_escape_clears_then_exits(self) -> None:
        """First escape clears text, second exits filter mode."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Plan A")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()

            # Activate filter and type
            await pilot.press("slash")
            await pilot.pause()
            await pilot.press("t", "e", "s", "t")
            await pilot.pause()

            from textual.widgets import Input

            from erk.tui.filtering.types import FilterMode

            filter_input = app.query_one("#filter-input", Input)
            assert filter_input.value == "test"
            assert app._filter_state.mode == FilterMode.ACTIVE

            # First escape clears text
            await pilot.press("escape")
            await pilot.pause()
            assert filter_input.value == ""
            assert app._filter_state.mode == FilterMode.ACTIVE

            # Second escape exits filter mode
            await pilot.press("escape")
            await pilot.pause()
            assert app._filter_state.mode == FilterMode.INACTIVE
            assert not filter_input.has_class("visible")

    @pytest.mark.asyncio
    async def test_enter_returns_focus_to_table(self) -> None:
        """Pressing Enter in filter input returns focus to table."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Plan A")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()

            # Activate filter
            await pilot.press("slash")
            await pilot.pause()

            from textual.widgets import Input

            filter_input = app.query_one("#filter-input", Input)
            assert app.focused == filter_input

            # Press Enter to return to table
            await pilot.press("enter")
            await pilot.pause()

            table = app.query_one(PlanDataTable)
            assert app.focused == table

    @pytest.mark.asyncio
    async def test_filter_by_issue_number(self) -> None:
        """Filter can match by issue number."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(123, "Plan A"),
                make_plan_row(456, "Plan B"),
                make_plan_row(789, "Plan C"),
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()

            await pilot.press("slash")
            await pilot.pause()
            await pilot.press("4", "5", "6")
            await pilot.pause()

            assert len(app._rows) == 1
            assert app._rows[0].issue_number == 456

    @pytest.mark.asyncio
    async def test_filter_by_pr_number(self) -> None:
        """Filter can match by PR number."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(1, "Plan A", pr_number=100),
                make_plan_row(2, "Plan B", pr_number=200),
                make_plan_row(3, "Plan C"),
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()

            await pilot.press("slash")
            await pilot.pause()
            await pilot.press("2", "0", "0")
            await pilot.pause()

            assert len(app._rows) == 1
            assert app._rows[0].issue_number == 2


class TestOpenRow:
    """Tests for 'o' key open behavior (PR-first, then issue)."""

    @pytest.mark.asyncio
    async def test_o_opens_pr_when_available(self) -> None:
        """'o' key opens PR URL when PR is available."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Feature",
                    pr_number=456,
                    pr_url="https://github.com/test/repo/pull/456",
                    issue_url="https://github.com/test/repo/issues/123",
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Press 'o' - should open PR (we can't actually open URL in test,
            # but we can check the status bar message)
            await pilot.press("o")
            await pilot.pause()

            status_bar = app.query_one(StatusBar)
            # Message should indicate PR was opened, not issue
            assert status_bar._message == "Opened PR #456"

    @pytest.mark.asyncio
    async def test_o_opens_issue_when_no_pr(self) -> None:
        """'o' key opens issue URL when no PR is available."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Feature",
                    issue_url="https://github.com/test/repo/issues/123",
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("o")
            await pilot.pause()

            status_bar = app.query_one(StatusBar)
            # Message should indicate issue was opened
            assert status_bar._message == "Opened issue #123"


class TestOnLearnClicked:
    """Tests for on_learn_clicked event handler (learn cell click)."""

    @pytest.mark.asyncio
    async def test_learn_click_opens_pr_when_both_pr_and_issue_set(self) -> None:
        """Learn click opens PR URL when both PR and issue are set (PR priority)."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Feature",
                    issue_url="https://github.com/test/repo/issues/123",
                    learn_status="plan_completed",
                    learn_plan_issue=456,
                    learn_plan_pr=789,
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Emit LearnClicked event for row 0
            table = app.query_one(PlanDataTable)
            table.post_message(PlanDataTable.LearnClicked(row_index=0))
            await pilot.pause()

            # Browser should have opened PR URL, not issue URL
            assert provider.browser.last_launched == "https://github.com/test/repo/pull/789"

            # Status bar should show PR message
            status_bar = app.query_one(StatusBar)
            assert status_bar._message == "Opened learn PR #789"

    @pytest.mark.asyncio
    async def test_learn_click_opens_issue_when_only_issue_set(self) -> None:
        """Learn click opens issue URL when only learn_plan_issue is set."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Feature",
                    issue_url="https://github.com/test/repo/issues/123",
                    learn_status="completed_with_plan",
                    learn_plan_issue=456,
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Emit LearnClicked event for row 0
            table = app.query_one(PlanDataTable)
            table.post_message(PlanDataTable.LearnClicked(row_index=0))
            await pilot.pause()

            # Browser should have opened issue URL
            assert provider.browser.last_launched == "https://github.com/test/repo/issues/456"

            # Status bar should show issue message
            status_bar = app.query_one(StatusBar)
            assert status_bar._message == "Opened learn issue #456"

    @pytest.mark.asyncio
    async def test_learn_click_does_nothing_when_no_learn_data(self) -> None:
        """Learn click does nothing when no learn fields are set."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Feature",
                    issue_url="https://github.com/test/repo/issues/123",
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Emit LearnClicked event for row 0
            table = app.query_one(PlanDataTable)
            table.post_message(PlanDataTable.LearnClicked(row_index=0))
            await pilot.pause()

            # Browser should NOT have been called
            assert provider.browser.last_launched is None

    @pytest.mark.asyncio
    async def test_learn_click_does_nothing_when_no_issue_url(self) -> None:
        """Learn click does nothing when issue_url is empty (URL can't be constructed)."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Feature",
                    issue_url="",  # Empty string to represent no issue URL
                    learn_status="plan_completed",
                    learn_plan_pr=789,
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Emit LearnClicked event for row 0
            table = app.query_one(PlanDataTable)
            table.post_message(PlanDataTable.LearnClicked(row_index=0))
            await pilot.pause()

            # Browser should NOT have been called (can't construct URL)
            assert provider.browser.last_launched is None


class TestPlanDetailScreen:
    """Tests for PlanDetailScreen modal."""

    @pytest.mark.asyncio
    async def test_space_opens_detail_screen(self) -> None:
        """Pressing space opens the plan detail modal."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", pr_number=456, pr_title="Test PR")]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Press space to show detail
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            # Detail screen should be in the screen stack
            assert len(app.screen_stack) > 1
            assert isinstance(app.screen_stack[-1], PlanDetailScreen)

    @pytest.mark.asyncio
    async def test_detail_modal_dismisses_on_escape(self) -> None:
        """Detail modal closes when pressing escape."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Test Plan")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail modal
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            # Should be showing detail
            assert isinstance(app.screen_stack[-1], PlanDetailScreen)

            # Press escape to close
            await pilot.press("escape")
            await pilot.pause()

            # Should be back to main screen
            assert not isinstance(app.screen_stack[-1], PlanDetailScreen)

    @pytest.mark.asyncio
    async def test_detail_modal_dismisses_on_q(self) -> None:
        """Detail modal closes when pressing q."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Test Plan")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            assert isinstance(app.screen_stack[-1], PlanDetailScreen)

            await pilot.press("q")
            await pilot.pause()

            assert not isinstance(app.screen_stack[-1], PlanDetailScreen)

    @pytest.mark.asyncio
    async def test_detail_modal_dismisses_on_space(self) -> None:
        """Detail modal closes when pressing space again."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Test Plan")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            assert isinstance(app.screen_stack[-1], PlanDetailScreen)

            await pilot.press("space")
            await pilot.pause()

            assert not isinstance(app.screen_stack[-1], PlanDetailScreen)

    @pytest.mark.asyncio
    async def test_detail_modal_displays_full_title(self) -> None:
        """Detail modal shows full untruncated title."""
        long_title = (
            "This is a very long plan title that would normally be truncated "
            "in the table view but should be fully visible in the detail modal"
        )
        provider = FakePlanDataProvider(plans=[make_plan_row(123, long_title)])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)
            # The modal stores the full row data which contains full_title
            assert detail_screen._row.full_title == long_title

    @pytest.mark.asyncio
    async def test_detail_modal_shows_pr_info_when_linked(self) -> None:
        """Detail modal shows PR information when PR is linked."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Test Plan",
                    pr_number=456,
                    pr_title="Test PR Title",
                    pr_state="OPEN",
                    pr_url="https://github.com/test/repo/pull/456",
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)
            assert detail_screen._row.pr_number == 456
            assert detail_screen._row.pr_title == "Test PR Title"
            assert detail_screen._row.pr_state == "OPEN"


class TestPlanDetailScreenCopyActions:
    """Tests for PlanDetailScreen copy keyboard shortcuts."""

    @pytest.mark.asyncio
    async def test_copy_prepare_shortcut_1(self) -> None:
        """Pressing '1' in detail screen copies prepare command."""
        clipboard = FakeClipboard()
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")],
            clipboard=clipboard,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            # Press '1' to copy prepare command
            await pilot.press("1")
            await pilot.pause()

            assert clipboard.last_copied == "erk prepare 123"

    @pytest.mark.asyncio
    async def test_copy_submit_shortcut_3(self) -> None:
        """Pressing '3' in detail screen copies submit command."""
        clipboard = FakeClipboard()
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")],
            clipboard=clipboard,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            await pilot.press("3")
            await pilot.pause()

            assert clipboard.last_copied == "erk plan submit 123"

    @pytest.mark.asyncio
    async def test_copy_checkout_shortcut_c_with_local_worktree(self) -> None:
        """Pressing 'c' in detail screen copies checkout command for local worktree."""
        clipboard = FakeClipboard()
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Test Plan",
                    worktree_name="feature-123",
                    worktree_branch="feature-123",
                    exists_locally=True,
                )
            ],
            clipboard=clipboard,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            await pilot.press("c")
            await pilot.pause()

            assert clipboard.last_copied == "erk br co feature-123"

    @pytest.mark.asyncio
    async def test_copy_pr_checkout_shortcut_e(self) -> None:
        """Pressing 'e' in detail screen copies PR checkout command."""
        clipboard = FakeClipboard()
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Test Plan",
                    pr_number=456,
                    exists_locally=False,
                )
            ],
            clipboard=clipboard,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            await pilot.press("e")
            await pilot.pause()

            assert clipboard.last_copied == "erk pr co 456"


class TestCommandPaletteFromMain:
    """Tests for command palette from main list view.

    Ctrl+P opens the command palette directly from main list (no detail modal).
    The palette shows plan-specific commands for the selected row.
    """

    @pytest.mark.asyncio
    async def test_execute_palette_command_copy_prepare(self) -> None:
        """Execute palette command copies prepare command."""
        clipboard = FakeClipboard()
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")],
            clipboard=clipboard,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Execute command directly (simulates palette selection)
            app.execute_palette_command("copy_prepare")

            assert clipboard.last_copied == "erk prepare 123"

    @pytest.mark.asyncio
    async def test_execute_palette_command_open_pr(self) -> None:
        """Execute palette command opens PR in browser."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123, "Test Plan", pr_number=456, pr_url="https://github.com/test/pr/456"
                )
            ]
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Execute open_pr command
            app.execute_palette_command("open_pr")

            assert provider.browser.last_launched == "https://github.com/test/pr/456"

    @pytest.mark.asyncio
    async def test_execute_palette_command_with_no_selection(self) -> None:
        """Execute palette command with no selection does nothing."""
        clipboard = FakeClipboard()
        provider = FakePlanDataProvider(plans=[], clipboard=clipboard)
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Execute command with no rows selected
            app.execute_palette_command("copy_prepare")

            # Nothing should be copied
            assert clipboard.last_copied is None

    @pytest.mark.asyncio
    async def test_space_opens_detail_screen(self) -> None:
        """Space opens detail screen without palette."""
        provider = FakePlanDataProvider(plans=[make_plan_row(123, "Test Plan")])
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Press space (regular detail view)
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)
            # The flag should NOT be set (space = just detail, no palette)
            assert detail_screen._auto_open_palette is False


class TestExecutePaletteCommandLandPR:
    """Tests for execute_palette_command('land_pr').

    Note: land_pr uses streaming output via subprocess. These tests verify
    the guard conditions. The guard condition test (no PR) doesn't invoke
    subprocess, so it can be tested without a real directory. Testing the
    positive case with actual subprocess execution is done via integration tests.
    """

    @pytest.mark.asyncio
    async def test_execute_palette_command_land_pr_with_no_pr(self) -> None:
        """Execute palette command land_pr does nothing if no PR."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")]  # No pr_number
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            initial_stack_len = len(app.screen_stack)

            # Execute land_pr command with no PR
            app.execute_palette_command("land_pr")
            await pilot.pause()

            # Should not have pushed a new screen
            assert len(app.screen_stack) == initial_stack_len

    @pytest.mark.asyncio
    async def test_execute_palette_command_land_pr_includes_force_flag(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Execute palette command land_pr calls erk exec land-execute with -f flag."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", pr_number=456, pr_head_branch="test-branch")],
            repo_root=tmp_path,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        # Capture the command passed to run_streaming_command
        captured_command = None

        def mock_run_streaming_command(
            self: PlanDetailScreen,
            command: list[str],
            cwd: Path,
            title: str,
            *,
            timeout: float = 30.0,
            on_success: Callable[[], None] | None = None,
        ) -> None:
            nonlocal captured_command
            captured_command = command

        # Patch run_streaming_command to capture the command
        monkeypatch.setattr(
            PlanDetailScreen,
            "run_streaming_command",
            mock_run_streaming_command,
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Execute land_pr command
            app.execute_palette_command("land_pr")
            await pilot.pause()

            # Verify command calls erk exec land-execute with -f flag
            assert captured_command is not None
            assert captured_command == [
                "erk",
                "exec",
                "land-execute",
                "--pr-number=456",
                "--branch=test-branch",
                "-f",
            ]
            assert "-f" in captured_command

    @pytest.mark.asyncio
    async def test_execute_palette_command_land_pr_with_no_branch(self) -> None:
        """Execute palette command land_pr does nothing if no pr_head_branch."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", pr_number=456)]  # Has PR but no pr_head_branch
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            initial_stack_len = len(app.screen_stack)

            # Execute land_pr command - should do nothing since no worktree_branch
            app.execute_palette_command("land_pr")
            await pilot.pause()

            # Should not have pushed a new screen
            assert len(app.screen_stack) == initial_stack_len


class TestExecutePaletteCommandFixConflictsRemote:
    """Tests for execute_palette_command('fix_conflicts_remote').

    Note: fix_conflicts_remote uses streaming output via subprocess. These tests verify
    the guard conditions. The guard condition test (no PR) doesn't invoke
    subprocess, so it can be tested without a real directory. Testing the
    positive case with actual subprocess execution is done via integration tests.
    """

    @pytest.mark.asyncio
    async def test_execute_palette_command_fix_conflicts_remote_with_no_pr(self) -> None:
        """Execute palette command fix_conflicts_remote does nothing if no PR."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")]  # No pr_number
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            initial_stack_len = len(app.screen_stack)

            # Execute fix_conflicts_remote command with no PR
            app.execute_palette_command("fix_conflicts_remote")
            await pilot.pause()

            # Should not have pushed a new screen
            assert len(app.screen_stack) == initial_stack_len

    @pytest.mark.asyncio
    async def test_execute_palette_command_fix_conflicts_remote_pushes_screen_and_runs_command(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Execute palette command fix_conflicts_remote pushes screen and runs correct command."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", pr_number=456)],
            repo_root=tmp_path,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        # Capture the command passed to run_streaming_command
        captured_command = None

        def mock_run_streaming_command(
            self: PlanDetailScreen,
            command: list[str],
            cwd: Path,
            title: str,
            *,
            timeout: float = 30.0,
            on_success: Callable[[], None] | None = None,
        ) -> None:
            nonlocal captured_command
            captured_command = command

        # Patch run_streaming_command to capture the command
        monkeypatch.setattr(
            PlanDetailScreen,
            "run_streaming_command",
            mock_run_streaming_command,
        )

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            initial_stack_len = len(app.screen_stack)

            # Execute fix_conflicts_remote command
            app.execute_palette_command("fix_conflicts_remote")
            await pilot.pause()

            # Should have pushed a new screen
            assert len(app.screen_stack) == initial_stack_len + 1

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Verify correct command was prepared
            assert captured_command is not None
            assert captured_command == ["erk", "pr", "fix-conflicts-remote", "456"]


class TestStreamingCommandTimeout:
    """Tests for streaming command timeout behavior.

    The streaming command timeout feature kills long-running subprocesses
    to prevent the TUI from hanging indefinitely.
    """

    @pytest.mark.asyncio
    async def test_timeout_fires_and_kills_process(self, tmp_path: Path) -> None:
        """Timeout kills subprocess and shows error message.

        Uses a short timeout (0.1s) with a sleep command to verify
        the timeout handler fires and terminates the process.
        """
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")],
            repo_root=tmp_path,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Run a command with very short timeout - sleep for 10 seconds
            # but timeout after 0.1 seconds
            detail_screen.run_streaming_command(
                ["sleep", "10"],
                cwd=tmp_path,
                title="Test Command",
                timeout=0.1,
            )

            # Wait for timeout to fire (0.1s + buffer)
            await pilot.pause(0.3)

            # Verify timeout was handled
            assert detail_screen._command_running is False
            panel = detail_screen._output_panel
            assert panel is not None
            assert panel.is_completed
            assert panel.succeeded is False

    @pytest.mark.asyncio
    async def test_successful_command_cancels_timer(self, tmp_path: Path) -> None:
        """Fast command completes before timeout and cancels timer."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")],
            repo_root=tmp_path,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Run a fast command with long timeout
            detail_screen.run_streaming_command(
                ["echo", "hello"],
                cwd=tmp_path,
                title="Test Command",
                timeout=30.0,
            )

            # Wait for command to complete
            await pilot.pause(0.3)

            # Command should have completed successfully
            assert detail_screen._command_running is False
            # Timer should have been cancelled (set to None)
            assert detail_screen._stream_timeout_timer is None
            panel = detail_screen._output_panel
            assert panel is not None
            assert panel.is_completed
            assert panel.succeeded is True

    @pytest.mark.asyncio
    async def test_timeout_disabled_when_zero(self, tmp_path: Path) -> None:
        """Setting timeout=0 disables the timeout timer."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")],
            repo_root=tmp_path,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Run command with timeout disabled
            detail_screen.run_streaming_command(
                ["echo", "hello"],
                cwd=tmp_path,
                title="Test Command",
                timeout=0,
            )

            # Timer should never have been set
            # (immediately after run_streaming_command, before async work)
            assert detail_screen._stream_timeout_timer is None

            # Wait for command to complete
            await pilot.pause(0.3)

            # Command should have completed normally
            assert detail_screen._command_running is False
            panel = detail_screen._output_panel
            assert panel is not None
            assert panel.is_completed
            assert panel.succeeded is True

    @pytest.mark.asyncio
    async def test_dismiss_blocked_during_command(self, tmp_path: Path) -> None:
        """Modal cannot be dismissed while command is running."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan")],
            repo_root=tmp_path,
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Start a long-running command
            detail_screen.run_streaming_command(
                ["sleep", "10"],
                cwd=tmp_path,
                title="Test Command",
                timeout=30.0,  # Won't fire during this test
            )

            # Command is running
            assert detail_screen._command_running is True

            # Try to dismiss - should be blocked
            await pilot.press("escape")
            await pilot.pause()

            # Modal should still be showing
            assert isinstance(app.screen_stack[-1], PlanDetailScreen)


class TestClosePlanInProcess:
    """Tests for run_close_plan_in_process functionality.

    This tests the in-process close plan action which uses the HTTP client
    directly rather than spawning a subprocess.
    """

    @pytest.mark.asyncio
    async def test_close_plan_in_process_creates_output_panel(self) -> None:
        """In-process close plan creates and mounts output panel."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(
                    123,
                    "Test Plan",
                    issue_url="https://github.com/test/repo/issues/123",
                )
            ],
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Run close plan in-process
            detail_screen.run_close_plan_in_process(123, "https://github.com/test/repo/issues/123")

            # Output panel should be created
            assert detail_screen._output_panel is not None
            assert detail_screen._command_running is True

            # Wait for worker to complete
            await pilot.pause(0.3)

            # Command should complete
            assert detail_screen._command_running is False

    @pytest.mark.asyncio
    async def test_close_plan_in_process_removes_plan_from_list(self) -> None:
        """In-process close plan removes the plan from provider."""
        provider = FakePlanDataProvider(
            plans=[
                make_plan_row(123, "Plan A"),
                make_plan_row(456, "Plan B"),
            ],
        )
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Should have 2 plans
            assert len(provider._plans) == 2

            # Open detail screen
            await pilot.press("space")
            await pilot.pause()
            await pilot.pause()

            detail_screen = app.screen_stack[-1]
            assert isinstance(detail_screen, PlanDetailScreen)

            # Close plan 123
            detail_screen.run_close_plan_in_process(123, "https://github.com/test/repo/issues/123")
            await pilot.pause(0.3)

            # Plan should be removed from provider
            assert len(provider._plans) == 1
            assert provider._plans[0].issue_number == 456


class TestIssueBodyScreen:
    """Tests for IssueBodyScreen modal (view plan text with async loading)."""

    @pytest.mark.asyncio
    async def test_v_key_opens_issue_body_screen(self) -> None:
        """Pressing 'v' opens the issue body modal."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", issue_body="metadata body")]
        )
        provider.set_plan_content(123, "# Test Plan\n\nThis is the plan content.")
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Press 'v' to view plan content
            await pilot.press("v")
            await pilot.pause()
            await pilot.pause()

            # IssueBodyScreen should be in the screen stack
            assert len(app.screen_stack) > 1
            assert isinstance(app.screen_stack[-1], IssueBodyScreen)

    @pytest.mark.asyncio
    async def test_issue_body_screen_fetches_and_shows_content(self) -> None:
        """IssueBodyScreen fetches and displays the plan content."""
        plan_content = "# Implementation Plan\n\n1. Step one\n2. Step two"
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", issue_body="metadata body")]
        )
        provider.set_plan_content(123, plan_content)
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("v")
            await pilot.pause()
            # Wait for async fetch to complete
            await pilot.pause(0.3)

            body_screen = app.screen_stack[-1]
            assert isinstance(body_screen, IssueBodyScreen)
            # Content should have been fetched
            assert body_screen._content == plan_content
            assert body_screen._loading is False

    @pytest.mark.asyncio
    async def test_issue_body_screen_dismisses_on_escape(self) -> None:
        """IssueBodyScreen closes when pressing escape."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", issue_body="metadata body")]
        )
        provider.set_plan_content(123, "Plan content")
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            # Open body screen
            await pilot.press("v")
            await pilot.pause()
            await pilot.pause()

            assert isinstance(app.screen_stack[-1], IssueBodyScreen)

            # Press escape to close
            await pilot.press("escape")
            await pilot.pause()

            assert not isinstance(app.screen_stack[-1], IssueBodyScreen)

    @pytest.mark.asyncio
    async def test_issue_body_screen_dismisses_on_q(self) -> None:
        """IssueBodyScreen closes when pressing q."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", issue_body="metadata body")]
        )
        provider.set_plan_content(123, "Plan content")
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("v")
            await pilot.pause()
            await pilot.pause()

            assert isinstance(app.screen_stack[-1], IssueBodyScreen)

            await pilot.press("q")
            await pilot.pause()

            assert not isinstance(app.screen_stack[-1], IssueBodyScreen)

    @pytest.mark.asyncio
    async def test_issue_body_screen_dismisses_on_space(self) -> None:
        """IssueBodyScreen closes when pressing space."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", issue_body="metadata body")]
        )
        provider.set_plan_content(123, "Plan content")
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("v")
            await pilot.pause()
            await pilot.pause()

            assert isinstance(app.screen_stack[-1], IssueBodyScreen)

            await pilot.press("space")
            await pilot.pause()

            assert not isinstance(app.screen_stack[-1], IssueBodyScreen)

    @pytest.mark.asyncio
    async def test_issue_body_screen_shows_empty_message_when_no_content(self) -> None:
        """IssueBodyScreen shows empty message when no plan content found."""
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", issue_body="metadata body")]
        )
        # Don't set plan content - fetch will return None
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("v")
            await pilot.pause()
            # Wait for async fetch to complete
            await pilot.pause(0.3)

            body_screen = app.screen_stack[-1]
            assert isinstance(body_screen, IssueBodyScreen)
            # Content should be None (not found)
            assert body_screen._content is None
            assert body_screen._loading is False

    @pytest.mark.asyncio
    async def test_issue_body_screen_shows_plan_number_and_title(self) -> None:
        """IssueBodyScreen shows plan number and full title in header."""
        full_title = "This is a very long plan title that should be shown in full"
        provider = FakePlanDataProvider(
            plans=[make_plan_row(456, full_title, issue_body="metadata body")]
        )
        provider.set_plan_content(456, "Plan content")
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("v")
            await pilot.pause()
            await pilot.pause()

            body_screen = app.screen_stack[-1]
            assert isinstance(body_screen, IssueBodyScreen)
            assert body_screen._issue_number == 456
            assert body_screen._full_title == full_title

    @pytest.mark.asyncio
    async def test_issue_body_screen_renders_content_as_markdown(self) -> None:
        """IssueBodyScreen renders plan content using Markdown widget."""
        plan_content = "# Header\n\n- List item 1\n- List item 2"
        provider = FakePlanDataProvider(
            plans=[make_plan_row(123, "Test Plan", issue_body="metadata body")]
        )
        provider.set_plan_content(123, plan_content)
        filters = PlanFilters.default()
        app = ErkDashApp(provider=provider, filters=filters, refresh_interval=0)

        async with app.run_test() as pilot:
            await pilot.pause()
            await pilot.pause()

            await pilot.press("v")
            await pilot.pause()
            # Wait for async fetch to complete
            await pilot.pause(0.3)

            body_screen = app.screen_stack[-1]
            assert isinstance(body_screen, IssueBodyScreen)

            # Verify content is rendered as Markdown widget
            content_widget = body_screen.query_one("#body-content", Markdown)
            assert content_widget is not None


class TestBuildGithubUrl:
    """Tests for _build_github_url helper function."""

    def test_build_github_url_for_pull_request(self) -> None:
        """_build_github_url constructs PR URL from issue URL."""
        issue_url = "https://github.com/owner/repo/issues/123"
        result = _build_github_url(issue_url, "pull", 456)
        assert result == "https://github.com/owner/repo/pull/456"

    def test_build_github_url_for_issue(self) -> None:
        """_build_github_url constructs issue URL from issue URL."""
        issue_url = "https://github.com/owner/repo/issues/123"
        result = _build_github_url(issue_url, "issues", 789)
        assert result == "https://github.com/owner/repo/issues/789"
