"""Main Textual application for erk dash interactive mode."""

from __future__ import annotations

import asyncio
import subprocess
import time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path

import click
from textual import on, work
from textual.app import App, ComposeResult, SystemCommand
from textual.binding import Binding
from textual.containers import Container
from textual.screen import Screen
from textual.widgets import Header, Input, Label

from erk.tui.commands.provider import MainListCommandProvider
from erk.tui.commands.types import CommandContext
from erk.tui.data.types import FetchTimings, PlanFilters, PlanRowData
from erk.tui.filtering.logic import filter_plans
from erk.tui.filtering.types import FilterMode, FilterState
from erk.tui.screens.check_runs_screen import CheckRunsScreen
from erk.tui.screens.help_screen import HelpScreen
from erk.tui.screens.launch_screen import LaunchScreen
from erk.tui.screens.plan_body_screen import PlanBodyScreen
from erk.tui.screens.plan_detail_screen import PlanDetailScreen
from erk.tui.screens.unresolved_comments_screen import UnresolvedCommentsScreen
from erk.tui.sorting.logic import sort_plans
from erk.tui.sorting.types import BranchActivity, SortKey, SortState
from erk.tui.views.types import (
    ViewMode,
    get_next_view_mode,
    get_previous_view_mode,
    get_view_config,
)
from erk.tui.widgets.plan_table import PlanDataTable
from erk.tui.widgets.status_bar import StatusBar
from erk.tui.widgets.view_bar import ViewBar
from erk_shared.gateway.command_executor.real import RealCommandExecutor
from erk_shared.gateway.plan_data_provider.abc import PlanDataProvider


@dataclass(frozen=True)
class _OperationResult:
    """Result of a streaming subprocess operation."""

    success: bool
    output_lines: tuple[str, ...]
    return_code: int


def _last_output_line(result: _OperationResult) -> str:
    """Return the last non-empty output line, or 'Unknown error'."""
    return next((ln for ln in reversed(result.output_lines) if ln), "Unknown error")


def _build_github_url(plan_url: str, resource_type: str, number: int) -> str:
    """Build a GitHub URL for a PR or issue from an existing plan URL.

    Args:
        plan_url: Base plan URL (e.g., https://github.com/owner/repo/pull/123)
        resource_type: Either "pull" or "issues"
        number: The PR or issue number

    Returns:
        Full URL (e.g., https://github.com/owner/repo/pull/456)
    """
    # Try /pull/ first (new plan-as-PR format), fall back to /issues/ (legacy)
    if "/pull/" in plan_url:
        base_url = plan_url.rsplit("/pull/", 1)[0]
    else:
        base_url = plan_url.rsplit("/issues/", 1)[0]
    return f"{base_url}/{resource_type}/{number}"


class ErkDashApp(App):
    """Interactive TUI for erk dash command.

    Displays plans in a navigable table with quick actions.
    """

    CSS_PATH = Path(__file__).parent / "styles" / "dash.tcss"
    COMMANDS = {MainListCommandProvider}

    BINDINGS = [
        Binding("q", "exit_app", "Quit"),
        Binding("escape", "exit_app", "Quit"),
        Binding("r", "refresh", "Refresh"),
        Binding("?", "help", "Help"),
        Binding("j", "cursor_down", "Down", show=False),
        Binding("k", "cursor_up", "Up", show=False),
        Binding("enter", "show_detail", "Detail"),
        Binding("space", "show_detail", "Detail", show=False),
        Binding("o", "toggle_objective_filter", "Objective", show=False),
        Binding("p", "open_pr", "Open PR"),
        Binding("n", "open_run", "Run"),
        Binding("c", "view_comments", "Comments", show=False),
        Binding("h", "view_checks", "Checks", show=False),
        Binding("i", "show_implement", "Implement"),
        Binding("v", "view_plan_body", "View", show=False),
        Binding("l", "launch", "Launch"),
        Binding("slash", "start_filter", "Filter", key_display="/"),
        Binding("s", "toggle_sort", "Sort"),
        Binding("ctrl+p", "command_palette", "Commands"),
        Binding("1", "switch_view_plans", "Plans", show=False),
        Binding("2", "switch_view_learn", "Learn", show=False),
        Binding("3", "switch_view_objectives", "Objectives", show=False),
        Binding("t", "toggle_stack_filter", "Stack", show=False),
        Binding("right", "next_view", "Next View", show=False, priority=True),
        Binding("left", "previous_view", "Previous View", show=False, priority=True),
    ]

    def get_system_commands(self, screen: Screen) -> Iterator[SystemCommand]:
        """Return system commands, hiding them when plan commands are available.

        Hides Keys, Quit, Screenshot, Theme from command palette when on
        PlanDetailScreen or when main list has a selected row, so only
        plan-specific commands appear.
        """
        if isinstance(screen, PlanDetailScreen):
            return iter(())
        # Hide system commands on main list when a row is selected
        if self._get_selected_row() is not None:
            return iter(())
        yield from super().get_system_commands(screen)

    def __init__(
        self,
        *,
        provider: PlanDataProvider,
        filters: PlanFilters,
        refresh_interval: float = 15.0,
        initial_sort: SortState | None = None,
    ) -> None:
        """Initialize the dashboard app.

        Args:
            provider: Data provider for fetching plan data
            filters: Filter options for the plan list
            refresh_interval: Seconds between auto-refresh (0 to disable)
            initial_sort: Initial sort state (defaults to by plan number)
        """
        super().__init__()
        self._provider = provider
        self._plan_filters = filters
        self._refresh_interval = refresh_interval
        self._table: PlanDataTable | None = None
        self._status_bar: StatusBar | None = None
        self._filter_input: Input | None = None
        self._all_rows: list[PlanRowData] = []  # Unfiltered data
        self._rows: list[PlanRowData] = []  # Currently displayed (possibly filtered)
        self._refresh_task: asyncio.Task | None = None
        self._loading = True
        self._filter_state = FilterState.initial()
        self._stack_filter_branches: frozenset[str] | None = None
        self._stack_filter_label: str | None = None
        self._objective_filter_issue: int | None = None
        self._objective_filter_label: str | None = None
        self._sort_state = initial_sort if initial_sort is not None else SortState.initial()
        self._activity_by_plan: dict[int, BranchActivity] = {}
        self._activity_loading = False
        self._view_mode: ViewMode = ViewMode.PLANS
        self._view_bar: ViewBar | None = None
        self._data_cache: dict[tuple[str, ...], list[PlanRowData]] = {}

    def _display_name_for_view(self, mode: ViewMode) -> str:
        """Get the display name for a view mode.

        Args:
            mode: The view mode to get a display name for

        Returns:
            Display name string
        """
        if mode == ViewMode.PLANS:
            return "Planned PRs"
        return get_view_config(mode).display_name

    def compose(self) -> ComposeResult:
        """Create the application layout."""
        yield Header(show_clock=True)
        yield ViewBar(
            active_view=self._view_mode,
            plans_display_name=self._display_name_for_view(ViewMode.PLANS),
        )
        with Container(id="main-container"):
            yield Label(
                f"Loading {self._display_name_for_view(ViewMode.PLANS).lower()}...",
                id="loading-message",
            )
            yield PlanDataTable(self._plan_filters)
        yield Input(id="filter-input", placeholder="Filter...", disabled=True)
        yield StatusBar()

    def on_mount(self) -> None:
        """Initialize app after mounting."""
        self._table = self.query_one(PlanDataTable)
        self._status_bar = self.query_one(StatusBar)
        self._filter_input = self.query_one("#filter-input", Input)
        self._loading_label = self.query_one("#loading-message", Label)
        self._view_bar = self.query_one(ViewBar)

        # Hide table until loaded
        self._table.display = False

        # Start data loading
        self.run_worker(self._load_data(), exclusive=True)

        # Start refresh timer if interval > 0
        if self._refresh_interval > 0:
            self._start_refresh_timer()

    async def _load_data(self) -> None:
        """Load plan data in background thread."""
        # Track fetch timing
        start_time = time.monotonic()

        # Snapshot view mode at fetch start to avoid race conditions.
        # If the user switches tabs during the fetch, self._view_mode changes
        # but fetched_mode retains the original value so results are cached
        # under the correct key and stale results don't overwrite the display.
        fetched_mode = self._view_mode
        view_config = get_view_config(fetched_mode)
        active_filters = PlanFilters(
            labels=view_config.labels,
            state=self._plan_filters.state,
            run_state=self._plan_filters.run_state,
            limit=self._plan_filters.limit,
            show_prs=self._plan_filters.show_prs,
            show_runs=self._plan_filters.show_runs,
            creator=self._plan_filters.creator,
            exclude_labels=view_config.exclude_labels,
        )

        fetch_timings: FetchTimings | None = None
        try:
            # Run sync fetch in executor to avoid blocking
            loop = asyncio.get_running_loop()
            rows, fetch_timings = await loop.run_in_executor(
                None, self._provider.fetch_plans, active_filters
            )

            # If sorting by activity, also fetch activity data
            if self._sort_state.key == SortKey.BRANCH_ACTIVITY:
                activity = await loop.run_in_executor(
                    None, self._provider.fetch_branch_activity, rows
                )
                self._activity_by_plan = activity

        except Exception as e:
            # GitHub API failure, network error, etc.
            self.notify(f"Failed to load plans: {e}", severity="error", timeout=5)
            rows = []

        # Calculate duration
        duration = time.monotonic() - start_time
        update_time = datetime.now().strftime("%H:%M:%S")

        # Update UI directly since we're in async context
        self._update_table(
            rows, update_time, duration, fetched_mode=fetched_mode, fetch_timings=fetch_timings
        )

    def _update_table(
        self,
        rows: list[PlanRowData],
        update_time: str | None,
        duration: float | None,
        *,
        fetched_mode: ViewMode,
        fetch_timings: FetchTimings | None = None,
    ) -> None:
        """Update table with new data.

        Args:
            rows: Plan data to display
            update_time: Formatted time of this update
            duration: Duration of the fetch in seconds
            fetched_mode: The view mode that was active when the fetch started.
                Data is cached under that mode's labels and the display is only
                updated if it still matches the current view.
            fetch_timings: Optional timing breakdown for each fetch phase
        """
        # Cache under the FETCHED view's labels (always correct)
        fetched_config = get_view_config(fetched_mode)
        self._data_cache[fetched_config.labels] = rows

        # If user switched tabs during fetch, don't touch the display
        if fetched_mode != self._view_mode:
            return

        rows = self._filter_rows_for_view(rows, self._view_mode)

        self._all_rows = rows
        self._loading = False

        # Apply filter and sort
        self._rows = self._apply_filter_and_sort(rows)

        if self._table is not None:
            self._loading_label.display = False
            self._table.display = True
            self._table.populate(self._rows)

        if self._status_bar is not None:
            noun = self._display_name_for_view(self._view_mode).lower()
            self._status_bar.set_plan_count(len(self._rows), noun=noun)
            self._status_bar.set_sort_mode(self._sort_state.display_label)
            if update_time is not None:
                self._status_bar.set_last_update(update_time, duration, fetch_timings=fetch_timings)

    def _apply_filter_and_sort(self, rows: list[PlanRowData]) -> list[PlanRowData]:
        """Apply current filter and sort to rows.

        Filter pipeline: objective filter → stack filter → text filter → sort.

        Args:
            rows: Raw rows to process

        Returns:
            Filtered and sorted rows
        """
        # Apply objective filter first
        if self._objective_filter_issue is not None:
            rows = [r for r in rows if r.objective_issue == self._objective_filter_issue]

        # Apply stack filter
        if self._stack_filter_branches is not None:
            rows = [
                r
                for r in rows
                if r.pr_head_branch is not None and r.pr_head_branch in self._stack_filter_branches
            ]

        # Apply text filter
        if self._filter_state.mode == FilterMode.ACTIVE and self._filter_state.query:
            filtered = filter_plans(rows, self._filter_state.query)
        else:
            filtered = rows

        # Apply sort
        return sort_plans(
            filtered,
            self._sort_state.key,
            self._activity_by_plan if self._sort_state.key == SortKey.BRANCH_ACTIVITY else None,
        )

    @staticmethod
    def _filter_rows_for_view(rows: list[PlanRowData], mode: ViewMode) -> list[PlanRowData]:
        """Filter rows based on view mode.

        Plans view excludes learn plans; Learn view includes only learn plans.

        Args:
            rows: Raw rows to filter
            mode: The active view mode

        Returns:
            Filtered rows for the given view
        """
        # Server-side label AND logic now correctly splits Plans/Learn.
        # Keep as pass-through safety net.
        return rows

    def _notify_with_severity(self, message: str, severity: str | None) -> None:
        """Wrapper for notify that handles optional severity.

        Args:
            message: The notification message
            severity: Optional severity level, uses default if None
        """
        if severity is None:
            self.notify(message)
        else:
            # Ensure severity is one of the valid values expected by Textual
            from textual.app import SeverityLevel

            if severity in ("information", "warning", "error"):
                valid_severity: SeverityLevel = severity  # type: ignore[assignment]
                self.notify(message, severity=valid_severity)
            else:
                # Fallback to default severity for unknown values
                self.notify(message)

    def _start_refresh_timer(self) -> None:
        """Start the auto-refresh countdown timer."""
        self._seconds_remaining = int(self._refresh_interval)
        self.set_interval(1.0, self._tick_countdown)

    def _tick_countdown(self) -> None:
        """Handle countdown timer tick."""
        if self._status_bar is not None:
            self._status_bar.set_refresh_countdown(self._seconds_remaining)

        self._seconds_remaining -= 1
        if self._seconds_remaining <= 0:
            self.action_refresh()
            self._seconds_remaining = int(self._refresh_interval)

    def action_exit_app(self) -> None:
        """Quit the application or handle progressive escape from filter mode.

        Progressive escape order: objective filter → stack filter → text content → text mode → quit.
        """
        if self._objective_filter_issue is not None:
            self._clear_objective_filter()
            return
        if self._stack_filter_branches is not None:
            self._clear_stack_filter()
            return
        if self._filter_state.mode == FilterMode.ACTIVE:
            self._filter_state = self._filter_state.handle_escape()
            if self._filter_state.mode == FilterMode.INACTIVE:
                # Fully exited filter mode
                self._exit_filter_mode()
            else:
                # Just cleared text, stay in filter mode
                if self._filter_input is not None:
                    self._filter_input.value = ""
                # Reset to show all rows
                self._apply_filter()
            return
        self.exit()

    def action_refresh(self) -> None:
        """Refresh plan data and reset countdown timer."""
        # Reset countdown timer
        if self._refresh_interval > 0:
            self._seconds_remaining = int(self._refresh_interval)
        self.run_worker(self._load_data(), exclusive=True)

    def action_help(self) -> None:
        """Show help screen."""
        self.push_screen(HelpScreen())

    def action_launch(self) -> None:
        """Open the launch screen for quick ACTION command execution."""
        row = self._get_selected_row()
        if row is None:
            return
        ctx = CommandContext(
            row=row,
            view_mode=self._view_mode,
        )
        self.push_screen(LaunchScreen(ctx=ctx), self._on_launch_result)

    def _on_launch_result(self, command_id: str | None) -> None:
        """Handle result from the launch screen.

        Args:
            command_id: The selected command ID, or None if cancelled
        """
        if command_id is not None:
            self.execute_palette_command(command_id)

    def _switch_view(self, mode: ViewMode) -> None:
        """Switch to a different view mode.

        Caches current data, reconfigures the table, and loads new data.

        Args:
            mode: The view mode to switch to
        """
        if mode == self._view_mode:
            return

        # Clear filters when switching views
        self._stack_filter_branches = None
        self._stack_filter_label = None
        self._objective_filter_issue = None
        self._objective_filter_label = None

        self._view_mode = mode
        view_config = get_view_config(mode)

        # Update view bar
        if self._view_bar is not None:
            self._view_bar.set_active_view(mode)

        # Reconfigure table columns for the new view
        if self._table is not None:
            self._table.reconfigure(
                plan_filters=self._plan_filters,
                view_mode=mode,
            )

        # Check cache for the new view's labels
        cached_data = self._data_cache.get(view_config.labels)
        if cached_data is not None:
            rows = self._filter_rows_for_view(cached_data, mode)
            self._all_rows = rows
            self._rows = self._apply_filter_and_sort(rows)
            if self._table is not None:
                self._table.populate(self._rows)
            if self._status_bar is not None:
                noun = self._display_name_for_view(mode).lower()
                self._status_bar.set_plan_count(len(self._rows), noun=noun)
        else:
            # No cached data - fetch fresh
            self.run_worker(self._load_data(), exclusive=True)

    def action_switch_view_plans(self) -> None:
        """Switch to Plans view."""
        self._switch_view(ViewMode.PLANS)

    def action_switch_view_learn(self) -> None:
        """Switch to Learn view."""
        self._switch_view(ViewMode.LEARN)

    def action_switch_view_objectives(self) -> None:
        """Switch to Objectives view."""
        self._switch_view(ViewMode.OBJECTIVES)

    def action_next_view(self) -> None:
        """Cycle to the next view (right arrow)."""
        self._switch_view(get_next_view_mode(self._view_mode))

    def action_previous_view(self) -> None:
        """Cycle to the previous view (left arrow)."""
        self._switch_view(get_previous_view_mode(self._view_mode))

    def action_toggle_stack_filter(self) -> None:
        """Toggle stack filter for the focused row's Graphite stack.

        If stack filter is active, clears it (toggle off).
        If no row selected or row has no branch, shows status message.
        If branch is not in a Graphite stack, shows status message.
        Otherwise, filters table to only show rows in the same stack.
        """
        # Toggle off if already active
        if self._stack_filter_branches is not None:
            self._clear_stack_filter()
            return

        row = self._get_selected_row()
        if row is None or row.pr_head_branch is None:
            if self._status_bar is not None:
                self._status_bar.set_message("No branch for stack filter")
            return

        stack = self._provider.get_branch_stack(row.pr_head_branch)
        if stack is None:
            if self._status_bar is not None:
                self._status_bar.set_message("Not in a Graphite stack")
            return

        self._stack_filter_branches = frozenset(stack)
        self._stack_filter_label = row.pr_head_branch
        self._apply_filter()

        if self._status_bar is not None:
            self._status_bar.set_message(f"Stack: {row.pr_head_branch} ({len(stack)})")

    def _clear_stack_filter(self) -> None:
        """Clear the stack filter and restore all rows."""
        self._stack_filter_branches = None
        self._stack_filter_label = None
        self._apply_filter()
        if self._status_bar is not None:
            self._status_bar.set_message(None)

    def action_toggle_objective_filter(self) -> None:
        """Toggle objective filter for the focused row's objective.

        If objective filter is active, clears it (toggle off).
        If no row selected or row has no objective_issue, shows status message.
        Otherwise, filters table to only show rows sharing the same objective.
        """
        # Toggle off if already active
        if self._objective_filter_issue is not None:
            self._clear_objective_filter()
            return

        row = self._get_selected_row()
        if row is None:
            return

        if row.objective_issue is None:
            if self._status_bar is not None:
                self._status_bar.set_message("Plan not linked to an objective")
            return

        self._objective_filter_issue = row.objective_issue
        self._objective_filter_label = str(row.objective_issue)
        self._apply_filter()

        count = len(self._rows)
        if self._status_bar is not None:
            self._status_bar.set_message(f"Objective: #{row.objective_issue} ({count})")

    def _clear_objective_filter(self) -> None:
        """Clear the objective filter and restore all rows."""
        self._objective_filter_issue = None
        self._objective_filter_label = None
        self._apply_filter()
        if self._status_bar is not None:
            self._status_bar.set_message(None)

    def action_toggle_sort(self) -> None:
        """Toggle between sort modes."""
        self._sort_state = self._sort_state.toggle()

        # If switching to activity sort, load activity data in background
        if self._sort_state.key == SortKey.BRANCH_ACTIVITY and not self._activity_by_plan:
            self._load_activity_and_resort()
        else:
            # Re-sort with current data
            self._rows = self._apply_filter_and_sort(self._all_rows)
            if self._table is not None:
                self._table.populate(self._rows)

        # Update status bar
        if self._status_bar is not None:
            self._status_bar.set_sort_mode(self._sort_state.display_label)

    @work(thread=True)
    def _load_activity_and_resort(self) -> None:
        """Load branch activity in background, then resort."""
        self._activity_loading = True

        # Fetch activity data
        activity = self._provider.fetch_branch_activity(self._all_rows)

        # Update on main thread
        self.app.call_from_thread(self._on_activity_loaded, activity)

    def _on_activity_loaded(self, activity: dict[int, BranchActivity]) -> None:
        """Handle activity data loaded - resort the table."""
        self._activity_by_plan = activity
        self._activity_loading = False

        # Re-sort with new activity data
        self._rows = self._apply_filter_and_sort(self._all_rows)
        if self._table is not None:
            self._table.populate(self._rows)

    def _start_operation(self, *, op_id: str, label: str) -> None:
        """Register a background operation in the status bar."""
        if self._status_bar is not None:
            self._status_bar.start_operation(op_id=op_id, label=label)

    def _update_operation(self, *, op_id: str, progress: str) -> None:
        """Update the progress line for a background operation."""
        if self._status_bar is not None:
            self._status_bar.update_operation(op_id=op_id, progress=progress)

    def _finish_operation(self, *, op_id: str) -> None:
        """Remove a completed background operation from the status bar."""
        if self._status_bar is not None:
            self._status_bar.finish_operation(op_id=op_id)

    def _run_streaming_operation(
        self,
        *,
        op_id: str,
        command: list[str],
    ) -> _OperationResult:
        """Run a subprocess with live output streaming to the status bar.

        Must be called from a background thread (@work(thread=True)).
        Uses Popen with merged stdout/stderr for real-time progress.

        Args:
            op_id: Operation identifier for status bar updates
            command: Command and arguments to execute

        Returns:
            Result with success flag, collected output lines, and return code
        """
        output_lines: list[str] = []
        proc = subprocess.Popen(
            command,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            bufsize=1,
            text=True,
            cwd=str(self._provider.repo_root),
        )
        if proc.stdout is not None:
            for line in proc.stdout:
                clean = click.unstyle(line.rstrip())
                if clean:
                    output_lines.append(clean)
                    self.call_from_thread(self._update_operation, op_id=op_id, progress=clean)
        return_code = proc.wait()
        return _OperationResult(
            success=return_code == 0,
            output_lines=tuple(output_lines),
            return_code=return_code,
        )

    @work(thread=True)
    def _close_plan_async(self, op_id: str, plan_id: int, plan_url: str) -> None:
        """Close plan in background thread with toast notifications.

        Args:
            op_id: Operation identifier for status bar tracking
            plan_id: The plan identifier
            plan_url: The plan URL
        """
        # Error boundary: catch all exceptions from the close operation to display
        # them as toast notifications rather than crashing the TUI.
        try:
            closed_prs = self._provider.close_plan(plan_id, plan_url)
            # Success toast
            if closed_prs:
                msg = f"Closed plan #{plan_id} (and {len(closed_prs)} linked PRs)"
            else:
                msg = f"Closed plan #{plan_id}"
            self.call_from_thread(self._finish_operation, op_id=op_id)
            self.call_from_thread(self.notify, msg, timeout=3)
            # Trigger data refresh
            self.call_from_thread(self.action_refresh)
        except Exception as e:
            # Error toast
            self.call_from_thread(self._finish_operation, op_id=op_id)
            self.call_from_thread(
                self.notify,
                f"Failed to close plan #{plan_id}: {e}",
                severity="error",
                timeout=5,
            )

    @work(thread=True)
    def _address_remote_async(self, op_id: str, pr_number: int) -> None:
        """Dispatch address-remote workflow in background thread with toast."""
        result = self._run_streaming_operation(
            op_id=op_id,
            command=["erk", "launch", "pr-address", "--pr", str(pr_number)],
        )
        self.call_from_thread(self._finish_operation, op_id=op_id)
        if result.success:
            metadata_updated = any(
                "Updated dispatch metadata" in line for line in result.output_lines
            )
            if metadata_updated:
                self.call_from_thread(
                    self.notify, f"Dispatched address for PR #{pr_number}", timeout=3
                )
            else:
                self.call_from_thread(
                    self.notify,
                    f"Dispatched address for PR #{pr_number} (metadata not updated)",
                    timeout=5,
                )
            self.call_from_thread(self.action_refresh)
        else:
            error_msg = _last_output_line(result)
            self.call_from_thread(
                self.notify,
                f"Failed to dispatch address for PR #{pr_number}: {error_msg}",
                severity="error",
                timeout=5,
            )

    @work(thread=True)
    def _fix_conflicts_remote_async(self, op_id: str, pr_number: int) -> None:
        """Dispatch fix-conflicts workflow in background thread with toast."""
        result = self._run_streaming_operation(
            op_id=op_id,
            command=["erk", "launch", "pr-fix-conflicts", "--pr", str(pr_number)],
        )
        self.call_from_thread(self._finish_operation, op_id=op_id)
        if result.success:
            metadata_updated = any(
                "Updated dispatch metadata" in line for line in result.output_lines
            )
            if metadata_updated:
                self.call_from_thread(
                    self.notify,
                    f"Dispatched: erk launch pr-fix-conflicts --pr {pr_number}",
                    timeout=3,
                )
            else:
                self.call_from_thread(
                    self.notify,
                    f"Dispatched: erk launch pr-fix-conflicts --pr {pr_number}"
                    " (metadata not updated)",
                    timeout=5,
                )
            self.call_from_thread(self.action_refresh)
        else:
            error_msg = _last_output_line(result)
            self.call_from_thread(
                self.notify,
                f"Failed to dispatch fix-conflicts for PR #{pr_number}: {error_msg}",
                severity="error",
                timeout=5,
            )

    @work(thread=True)
    def _land_pr_async(
        self,
        op_id: str,
        pr_number: int,
        branch: str,
        objective_issue: int | None,
    ) -> None:
        """Land PR in background thread with toast."""
        result = self._run_streaming_operation(
            op_id=op_id,
            command=[
                "erk",
                "exec",
                "land-execute",
                f"--pr-number={pr_number}",
                f"--branch={branch}",
                "-f",
            ],
        )
        if not result.success:
            error_msg = _last_output_line(result)
            self.call_from_thread(self._finish_operation, op_id=op_id)
            self.call_from_thread(
                self.notify,
                f"Failed to land PR #{pr_number}: {error_msg}",
                severity="error",
                timeout=5,
            )
            return

        self.call_from_thread(self.notify, f"Landed PR #{pr_number}", timeout=3)
        self.call_from_thread(self.action_refresh)

        if objective_issue is not None:
            self.call_from_thread(
                self._update_operation,
                op_id=op_id,
                progress=f"Updating objective #{objective_issue}...",
            )
            obj_result = self._run_streaming_operation(
                op_id=op_id,
                command=[
                    "erk",
                    "exec",
                    "objective-update-after-land",
                    f"--objective={objective_issue}",
                    f"--pr={pr_number}",
                    f"--branch={branch}",
                ],
            )
            if obj_result.success:
                self.call_from_thread(
                    self.notify, f"Updated objective #{objective_issue}", timeout=3
                )
            else:
                error_msg = _last_output_line(obj_result)
                self.call_from_thread(
                    self.notify,
                    f"Failed to update objective #{objective_issue}: {error_msg}",
                    severity="error",
                    timeout=5,
                )

        self.call_from_thread(self._finish_operation, op_id=op_id)

    @work(thread=True)
    def _dispatch_to_queue_async(self, op_id: str, plan_id: int) -> None:
        """Dispatch plan to queue in background thread with toast."""
        result = self._run_streaming_operation(
            op_id=op_id,
            command=["erk", "pr", "dispatch", str(plan_id)],
        )
        self.call_from_thread(self._finish_operation, op_id=op_id)
        if result.success:
            self.call_from_thread(self.notify, f"Dispatched plan #{plan_id} to queue", timeout=3)
            self.call_from_thread(self.action_refresh)
        else:
            error_msg = _last_output_line(result)
            self.call_from_thread(
                self.notify,
                f"Failed to dispatch plan #{plan_id}: {error_msg}",
                severity="error",
                timeout=5,
            )

    @work(thread=True)
    def _close_objective_async(self, op_id: str, plan_id: int) -> None:
        """Close objective in background thread with toast."""
        result = self._run_streaming_operation(
            op_id=op_id,
            command=["erk", "objective", "close", str(plan_id), "--force"],
        )
        self.call_from_thread(self._finish_operation, op_id=op_id)
        if result.success:
            self.call_from_thread(self.notify, f"Closed objective #{plan_id}", timeout=3)
            self.call_from_thread(self.action_refresh)
        else:
            error_msg = _last_output_line(result)
            self.call_from_thread(
                self.notify,
                f"Failed to close objective #{plan_id}: {error_msg}",
                severity="error",
                timeout=5,
            )

    @work(thread=True)
    def _check_objective_async(self, op_id: str, plan_id: int) -> None:
        """Check objective in background thread with toast."""
        result = self._run_streaming_operation(
            op_id=op_id,
            command=["erk", "objective", "check", str(plan_id)],
        )
        self.call_from_thread(self._finish_operation, op_id=op_id)
        if result.success:
            self.call_from_thread(self.notify, f"Checked objective #{plan_id}", timeout=3)
        else:
            error_msg = _last_output_line(result)
            self.call_from_thread(
                self.notify,
                f"Failed to check objective #{plan_id}: {error_msg}",
                severity="error",
                timeout=5,
            )

    @work(thread=True)
    def _one_shot_plan_async(self, op_id: str, plan_id: int) -> None:
        """Dispatch one-shot plan in background thread with toast."""
        result = self._run_streaming_operation(
            op_id=op_id,
            command=["erk", "objective", "plan", str(plan_id), "--one-shot"],
        )
        self.call_from_thread(self._finish_operation, op_id=op_id)
        if result.success:
            self.call_from_thread(
                self.notify, f"Dispatched one-shot plan for objective #{plan_id}", timeout=3
            )
            self.call_from_thread(self.action_refresh)
        else:
            error_msg = _last_output_line(result)
            self.call_from_thread(
                self.notify,
                f"Failed to dispatch one-shot plan for objective #{plan_id}: {error_msg}",
                severity="error",
                timeout=5,
            )

    def action_show_detail(self) -> None:
        """Show plan detail modal for selected row."""
        row = self._get_selected_row()
        if row is None:
            return

        # Create executor with injected dependencies
        executor = RealCommandExecutor(
            browser_launch=self._provider.browser.launch,
            clipboard_copy=self._provider.clipboard.copy,
            close_plan_fn=self._provider.close_plan,
            notify_fn=self._notify_with_severity,
            refresh_fn=self.action_refresh,
            dispatch_to_queue_fn=self._provider.dispatch_to_queue,
        )

        self.push_screen(
            PlanDetailScreen(
                row=row,
                clipboard=self._provider.clipboard,
                browser=self._provider.browser,
                executor=executor,
                repo_root=self._provider.repo_root,
            )
        )

    def action_view_plan_body(self) -> None:
        """Display the plan/objective content in a modal (fetched on-demand)."""
        row = self._get_selected_row()
        if row is None:
            return
        content_type = "Objective" if self._view_mode == ViewMode.OBJECTIVES else "Plan"
        # Push screen that will fetch content on-demand
        self.push_screen(
            PlanBodyScreen(
                provider=self._provider,
                plan_id=row.plan_id,
                plan_body=row.plan_body,
                full_title=row.full_title,
                content_type=content_type,
            )
        )

    def action_view_comments(self) -> None:
        """Display unresolved PR review comments in a modal."""
        row = self._get_selected_row()
        if row is None:
            return
        if row.pr_number is None:
            if self._status_bar is not None:
                self._status_bar.set_message("No PR linked to this plan")
            return
        unresolved = row.total_comment_count - row.resolved_comment_count
        if unresolved == 0:
            if self._status_bar is not None:
                self._status_bar.set_message("No unresolved comments")
            return
        self.push_screen(
            UnresolvedCommentsScreen(
                provider=self._provider,
                pr_number=row.pr_number,
                full_title=row.full_title,
                resolved_count=row.resolved_comment_count,
                total_count=row.total_comment_count,
            )
        )

    def action_view_checks(self) -> None:
        """Display failing CI checks in a modal."""
        row = self._get_selected_row()
        if row is None:
            return
        if row.pr_number is None:
            if self._status_bar is not None:
                self._status_bar.set_message("No PR linked to this plan")
            return
        if row.checks_passing is None:
            if self._status_bar is not None:
                self._status_bar.set_message("No checks available")
            return
        if row.checks_passing is True:
            if self._status_bar is not None:
                self._status_bar.set_message("All checks passing")
            return
        passing_count, total_count = row.checks_counts if row.checks_counts is not None else (0, 0)
        self.push_screen(
            CheckRunsScreen(
                provider=self._provider,
                pr_number=row.pr_number,
                full_title=row.full_title,
                passing_count=passing_count,
                total_count=total_count,
            )
        )

    def action_cursor_down(self) -> None:
        """Move cursor down (vim j key)."""
        if self._table is not None:
            self._table.action_cursor_down()

    def action_cursor_up(self) -> None:
        """Move cursor up (vim k key)."""
        if self._table is not None:
            self._table.action_cursor_up()

    def action_start_filter(self) -> None:
        """Activate filter mode and focus the input."""
        if self._filter_input is None:
            return
        self._filter_state = self._filter_state.activate()
        self._filter_input.disabled = False
        self._filter_input.add_class("visible")
        self._filter_input.focus()

    def _apply_filter(self) -> None:
        """Apply current filter query to the table."""
        self._rows = self._apply_filter_and_sort(self._all_rows)

        if self._table is not None:
            self._table.populate(self._rows)

        if self._status_bar is not None:
            self._status_bar.set_plan_count(
                len(self._rows), noun=self._display_name_for_view(self._view_mode).lower()
            )

    def _exit_filter_mode(self) -> None:
        """Exit filter mode, restore all rows, and focus table."""
        if self._filter_input is not None:
            self._filter_input.value = ""
            self._filter_input.remove_class("visible")
            self._filter_input.disabled = True

        self._filter_state = FilterState.initial()
        self._rows = self._apply_filter_and_sort(self._all_rows)

        if self._table is not None:
            self._table.populate(self._rows)
            self._table.focus()

        if self._status_bar is not None:
            self._status_bar.set_plan_count(
                len(self._rows), noun=self._display_name_for_view(self._view_mode).lower()
            )

    def action_open_pr(self) -> None:
        """Open selected PR in browser."""
        row = self._get_selected_row()
        if row is None:
            return

        if row.pr_url:
            self._provider.browser.launch(row.pr_url)
            if self._status_bar is not None:
                self._status_bar.set_message(f"Opened PR #{row.pr_number}")
        else:
            if self._status_bar is not None:
                self._status_bar.set_message("No PR linked to this plan")

    def action_open_run(self) -> None:
        """Open selected workflow run in browser."""
        row = self._get_selected_row()
        if row is None:
            return

        if row.run_url is not None:
            self._provider.browser.launch(row.run_url)
            if self._status_bar is not None:
                run_id = row.run_url.rsplit("/", 1)[-1]
                self._status_bar.set_message(f"Opened run {run_id}")
        else:
            if self._status_bar is not None:
                self._status_bar.set_message("No workflow run linked to this plan")

    def action_show_implement(self) -> None:
        """Show implement command in status bar."""
        row = self._get_selected_row()
        if row is None:
            return

        cmd = f"erk implement {row.plan_id}"
        if self._status_bar is not None:
            self._status_bar.set_message(f"Copy: {cmd}")

    def action_copy_checkout(self) -> None:
        """Copy checkout command for selected row."""
        row = self._get_selected_row()
        if row is None:
            return
        self._copy_checkout_command(row)

    def action_close_plan(self) -> None:
        """Close the selected plan and its linked PRs (async with toast)."""
        row = self._get_selected_row()
        if row is None:
            return

        if row.plan_url is None:
            self.notify("Cannot close plan: no issue URL", severity="warning")
            return

        # Show persistent status bar message and run async - no blocking
        op_id = f"close-plan-{row.plan_id}"
        self._start_operation(op_id=op_id, label=f"Closing plan #{row.plan_id}...")
        self._close_plan_async(op_id, row.plan_id, row.plan_url)

    def _copy_checkout_command(self, row: PlanRowData) -> None:
        """Copy appropriate checkout command based on row state.

        If worktree exists locally, copies 'erk co {worktree_name}'.
        If only PR available, copies 'erk pr co {pr_number}'.
        Shows status message with result.

        Args:
            row: The plan row data to generate command from
        """
        # Determine which command to use
        if row.worktree_branch is not None:
            # Local worktree exists - use branch checkout
            cmd = f"erk br co {row.worktree_branch}"
        elif row.pr_number is not None:
            # No local worktree but PR exists - use PR checkout
            cmd = f"erk pr co {row.pr_number}"
        else:
            # Neither available
            if self._status_bar is not None:
                self._status_bar.set_message("No worktree or PR available for checkout")
            return

        # Copy to clipboard
        success = self._provider.clipboard.copy(cmd)

        # Show status message
        if self._status_bar is not None:
            if success:
                self._status_bar.set_message(f"Copied: {cmd}")
            else:
                self._status_bar.set_message(f"Clipboard unavailable. Copy manually: {cmd}")

    def _get_selected_row(self) -> PlanRowData | None:
        """Get currently selected row data."""
        if self._table is None:
            return None
        return self._table.get_selected_row_data()

    def execute_palette_command(self, command_id: str) -> None:
        """Execute a command from the palette on the selected row.

        Args:
            command_id: The ID of the command to execute
        """
        row = self._get_selected_row()
        if row is None:
            return

        if command_id == "open_browser":
            url = row.pr_url or row.plan_url
            if url:
                self._provider.browser.launch(url)
                self.notify(f"Opened {url}")

        elif command_id == "open_issue":
            if row.plan_url:
                self._provider.browser.launch(row.plan_url)
                self.notify(f"Opened plan #{row.plan_id}")

        elif command_id == "open_pr":
            if row.pr_url:
                self._provider.browser.launch(row.pr_url)
                self.notify(f"Opened PR #{row.pr_number}")

        elif command_id == "open_run":
            if row.run_url:
                self._provider.browser.launch(row.run_url)
                self.notify(f"Opened run {row.run_id_display}")

        elif command_id == "copy_checkout":
            self._copy_checkout_command(row)

        elif command_id == "copy_pr_checkout":
            cmd = f'source "$(erk pr checkout {row.pr_number} --script)"'
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

        elif command_id == "copy_implement_local":
            if row.pr_number is not None:
                cmd = (
                    f'source "$(erk pr checkout {row.pr_number} --script)"'
                    " && erk implement --dangerous"
                )
                self._provider.clipboard.copy(cmd)
                self.notify(f"Copied: {cmd}")

        elif command_id == "copy_dispatch":
            cmd = f"erk pr dispatch {row.plan_id}"
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

        elif command_id == "copy_replan":
            cmd = f"erk pr replan {row.plan_id}"
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

        elif command_id == "copy_land":
            if row.pr_number is not None:
                cmd = f"erk land {row.pr_number}"
                self._provider.clipboard.copy(cmd)
                self.notify(f"Copied: {cmd}")

        elif command_id == "copy_close_plan":
            cmd = f"erk pr close {row.plan_id}"
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

        elif command_id == "copy_fix_conflicts_remote":
            if row.pr_number is not None:
                cmd = f"erk launch pr-fix-conflicts --pr {row.pr_number}"
                self._provider.clipboard.copy(cmd)
                self.notify(f"Copied: {cmd}")

        elif command_id == "copy_address_remote":
            if row.pr_number is not None:
                cmd = f"erk launch pr-address --pr {row.pr_number}"
                self._provider.clipboard.copy(cmd)
                self.notify(f"Copied: {cmd}")

        elif command_id == "fix_conflicts_remote":
            if row.pr_number:
                op_id = f"fix-conflicts-pr-{row.pr_number}"
                self._start_operation(
                    op_id=op_id,
                    label=f"Dispatching fix-conflicts for PR #{row.pr_number}...",
                )
                self._fix_conflicts_remote_async(op_id, row.pr_number)

        elif command_id == "address_remote":
            if row.pr_number:
                op_id = f"address-pr-{row.pr_number}"
                self._start_operation(
                    op_id=op_id, label=f"Dispatching address for PR #{row.pr_number}..."
                )
                self._address_remote_async(op_id, row.pr_number)

        elif command_id == "close_plan":
            if row.plan_url:
                op_id = f"close-plan-{row.plan_id}"
                self._start_operation(op_id=op_id, label=f"Closing plan #{row.plan_id}...")
                self._close_plan_async(op_id, row.plan_id, row.plan_url)

        elif command_id == "dispatch_to_queue":
            if row.plan_url:
                op_id = f"dispatch-plan-{row.plan_id}"
                self._start_operation(
                    op_id=op_id, label=f"Dispatching plan #{row.plan_id} to queue..."
                )
                self._dispatch_to_queue_async(op_id, row.plan_id)

        elif command_id == "land_pr":
            if row.pr_number and row.pr_head_branch:
                op_id = f"land-pr-{row.pr_number}"
                self._start_operation(op_id=op_id, label=f"Landing PR #{row.pr_number}...")
                self._land_pr_async(
                    op_id,
                    row.pr_number,
                    row.pr_head_branch,
                    row.objective_issue,
                )

        elif command_id == "copy_replan":
            cmd = f"/erk:replan {row.plan_id}"
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

        # === OBJECTIVE COMMANDS ===
        elif command_id == "copy_plan":
            cmd = f"erk objective plan {row.plan_id}"
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

        elif command_id == "copy_view":
            cmd = f"erk objective view {row.plan_id}"
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

        elif command_id == "open_objective":
            if row.plan_url:
                self._provider.browser.launch(row.plan_url)
                self.notify(f"Opened objective #{row.plan_id}")

        elif command_id == "one_shot_plan":
            op_id = f"one-shot-plan-{row.plan_id}"
            self._start_operation(
                op_id=op_id,
                label=f"Dispatching one-shot plan for objective #{row.plan_id}...",
            )
            self._one_shot_plan_async(op_id, row.plan_id)

        elif command_id == "check_objective":
            op_id = f"check-objective-{row.plan_id}"
            self._start_operation(op_id=op_id, label=f"Checking objective #{row.plan_id}...")
            self._check_objective_async(op_id, row.plan_id)

        elif command_id == "close_objective":
            op_id = f"close-objective-{row.plan_id}"
            self._start_operation(op_id=op_id, label=f"Closing objective #{row.plan_id}...")
            self._close_objective_async(op_id, row.plan_id)

        elif command_id == "codespace_run_plan":
            cmd = f"erk codespace run objective plan {row.plan_id}"
            self._provider.clipboard.copy(cmd)
            self.notify(f"Copied: {cmd}")

    @on(PlanDataTable.RowSelected)
    def on_row_selected(self, event: PlanDataTable.RowSelected) -> None:
        """Handle Enter/double-click on row - show plan details."""
        self.action_show_detail()

    @on(Input.Changed, "#filter-input")
    def on_filter_changed(self, event: Input.Changed) -> None:
        """Handle filter input text changes."""
        self._filter_state = self._filter_state.with_query(event.value)
        self._apply_filter()

    @on(Input.Submitted, "#filter-input")
    def on_filter_submitted(self, event: Input.Submitted) -> None:
        """Handle Enter in filter input - return focus to table."""
        if self._table is not None:
            self._table.focus()

    @on(ViewBar.ViewTabClicked)
    def on_view_tab_clicked(self, event: ViewBar.ViewTabClicked) -> None:
        """Handle click on a view bar tab - switch to that view."""
        self._switch_view(event.view_mode)

    @on(PlanDataTable.PlanClicked)
    def on_plan_clicked(self, event: PlanDataTable.PlanClicked) -> None:
        """Handle click on plan cell - open issue in browser."""
        if event.row_index < len(self._rows):
            row = self._rows[event.row_index]
            if row.plan_url:
                self._provider.browser.launch(row.plan_url)
                if self._status_bar is not None:
                    self._status_bar.set_message(f"Opened plan #{row.plan_id}")

    @on(PlanDataTable.PrClicked)
    def on_pr_clicked(self, event: PlanDataTable.PrClicked) -> None:
        """Handle click on pr cell - open PR in browser."""
        if event.row_index < len(self._rows):
            row = self._rows[event.row_index]
            if row.pr_url:
                self._provider.browser.launch(row.pr_url)
                if self._status_bar is not None:
                    self._status_bar.set_message(f"Opened PR #{row.pr_number}")

    @on(PlanDataTable.LocalWtClicked)
    def on_local_wt_clicked(self, event: PlanDataTable.LocalWtClicked) -> None:
        """Handle click on local-wt cell - copy worktree name to clipboard."""
        if event.row_index < len(self._rows):
            row = self._rows[event.row_index]
            if row.worktree_name:
                success = self._provider.clipboard.copy(row.worktree_name)
                if success:
                    self.notify(f"Copied: {row.worktree_name}", timeout=2)
                else:
                    self.notify("Clipboard unavailable", severity="error", timeout=2)

    @on(PlanDataTable.RunIdClicked)
    def on_run_id_clicked(self, event: PlanDataTable.RunIdClicked) -> None:
        """Handle click on run-id cell - open run in browser."""
        if event.row_index < len(self._rows):
            row = self._rows[event.row_index]
            if row.run_url:
                self._provider.browser.launch(row.run_url)
                if self._status_bar is not None:
                    # Extract run ID from URL to avoid Rich markup in status bar
                    run_id = row.run_url.rsplit("/", 1)[-1]
                    self._status_bar.set_message(f"Opened run {run_id}")

    @on(PlanDataTable.DepsClicked)
    def on_deps_clicked(self, event: PlanDataTable.DepsClicked) -> None:
        """Handle click on head cell - open first blocking head plan in browser."""
        if event.row_index < len(self._rows):
            row = self._rows[event.row_index]
            if row.objective_head_plans:
                display, url = row.objective_head_plans[0]
                self._provider.browser.launch(url)
                if self._status_bar is not None:
                    self._status_bar.set_message(f"Opened plan {display}")

    @on(PlanDataTable.ObjectiveClicked)
    def on_objective_clicked(self, event: PlanDataTable.ObjectiveClicked) -> None:
        """Handle click on objective cell - open objective issue in browser."""
        if event.row_index < len(self._rows):
            row = self._rows[event.row_index]
            if row.objective_issue is not None and row.plan_url:
                self._provider.browser.launch(
                    _build_github_url(row.plan_url, "issues", row.objective_issue)
                )
                if self._status_bar is not None:
                    self._status_bar.set_message(f"Opened objective #{row.objective_issue}")
