"""List command implementation for Synapse CLI."""

from __future__ import annotations

import argparse
import contextlib
import json
import os
import sys
from collections.abc import Callable
from pathlib import Path
from threading import Event
from typing import TYPE_CHECKING, Any, Protocol

from synapse.file_safety import FileSafetyManager
from synapse.port_manager import PORT_RANGES
from synapse.registry import AgentRegistry

if TYPE_CHECKING:
    from rich.console import Console


class _TimeModule(Protocol):
    """Protocol for time module interface."""

    def strftime(self, format: str) -> str: ...
    def sleep(self, seconds: float) -> None: ...
    def time(self) -> float: ...


class ListCommand:
    """List running agents with Rich TUI and event-driven updates."""

    def __init__(
        self,
        registry_factory: Callable[[], AgentRegistry],
        is_process_alive: Callable[[int], bool],
        is_port_open: Callable[..., bool],
        clear_screen: Callable[[], None],
        time_module: _TimeModule | Any,
        print_func: Callable[[str], None],
    ) -> None:
        self._registry_factory = registry_factory
        self._is_process_alive = is_process_alive
        self._is_port_open = is_port_open
        self._clear_screen = clear_screen
        self._time = time_module
        self._print = print_func

    def _is_agent_alive(
        self, registry: AgentRegistry, agent_id: str, info: dict[str, Any]
    ) -> bool:
        """Check if an agent is alive and unregister if dead.

        Uses retry logic to handle race conditions when agents are
        transitioning between states (e.g., PROCESSING -> READY).

        Args:
            registry: AgentRegistry instance for cleanup.
            agent_id: Agent identifier.
            info: Agent info dictionary.

        Returns:
            True if agent is alive, False if dead (and unregistered).
        """
        pid = info.get("pid")
        port = info.get("port")

        # Check 1: PID must be alive
        if pid and not self._is_process_alive(pid):
            registry.unregister(agent_id)
            return False

        # Check 2: Port must be open (agent server responding)
        # Skip port check for PROCESSING agents (server may still be starting)
        is_starting = info.get("status", "-") == "PROCESSING"
        if is_starting or not port:
            return True

        # First port check with short timeout
        if self._is_port_open("localhost", port, timeout=0.5):
            return True

        # Port check failed - retry once with longer timeout
        # This handles race conditions during status transitions
        self._time.sleep(0.2)  # Brief delay before retry

        if self._is_port_open("localhost", port, timeout=1.0):
            return True

        # Both checks failed - agent is truly dead
        registry.unregister(agent_id)
        return False

    def _get_agent_data(
        self,
        registry: AgentRegistry,
        working_dir_filter: str | None = None,
    ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], bool]:
        """Get agent data formatted for Rich renderer.

        Args:
            registry: AgentRegistry instance.
            working_dir_filter: Optional filter for working directory (partial match,
                case-insensitive).

        Returns:
            Tuple of (agents_list, stale_locks, show_file_safety).
        """
        agents = registry.list_agents()
        file_safety = FileSafetyManager.from_env()
        show_file_safety = file_safety.enabled

        agents_list: list[dict[str, Any]] = []

        for agent_id, info in agents.items():
            if not self._is_agent_alive(registry, agent_id, info):
                continue

            pid = info.get("pid")
            working_dir_full = info.get("working_dir", "-")
            worktree_branch = info.get("worktree_branch")
            working_dir_short = (
                os.path.basename(working_dir_full) if working_dir_full != "-" else "-"
            )
            if worktree_branch:
                working_dir_short = f"[WT] {working_dir_short}"
            agent_data: dict[str, Any] = {
                "agent_id": agent_id,
                "agent_type": info.get("agent_type", "unknown"),
                "name": info.get("name"),
                "role": info.get("role"),
                "skill_set": info.get("skill_set"),
                "port": info.get("port", "-"),
                "status": info.get("status", "-"),
                "pid": pid or "-",
                "working_dir": working_dir_short,
                "working_dir_full": working_dir_full,
                "endpoint": info.get("endpoint", "-"),
                "tty_device": info.get("tty_device"),
                "zellij_pane_id": info.get("zellij_pane_id"),
                "current_task_preview": info.get("current_task_preview"),
                "task_received_at": info.get("task_received_at"),
                "transport": registry.get_transport_display(agent_id) or "-",
            }

            if show_file_safety:
                # Try PID first, then fall back to agent_id for CLI-acquired locks
                locks = (
                    file_safety.list_locks(pid=pid, include_stale=False) if pid else []
                )
                if not locks and agent_id:
                    locks = file_safety.list_locks(
                        agent_name=agent_id, include_stale=False
                    )
                first_lock_path = locks[0].get("file_path") if locks else None
                agent_data["editing_file"] = (
                    os.path.basename(first_lock_path) if first_lock_path else "-"
                )

            agents_list.append(agent_data)

        # Apply filter (partial match, case-insensitive) to TYPE, NAME, and WORKING_DIR
        if working_dir_filter:
            filter_lower = working_dir_filter.lower()
            agents_list = [
                a
                for a in agents_list
                if filter_lower in a["working_dir_full"].lower()
                or filter_lower in a.get("agent_type", "").lower()
                or filter_lower in (a.get("name") or "").lower()
            ]

        # Get stale locks
        stale_locks: list[dict[str, Any]] = []
        if show_file_safety:
            stale_locks = file_safety.get_stale_locks()

        return agents_list, stale_locks, show_file_safety

    def _setup_nonblocking_input(self) -> tuple[Any, Any] | None:
        """Set up non-blocking keyboard input.

        Returns:
            Tuple of (old_settings, old_flags) for restoration, or None if failed.
        """
        try:
            import termios
            import tty

            fd = sys.stdin.fileno()
            old_settings = termios.tcgetattr(fd)
            tty.setcbreak(fd)
            return (old_settings, fd)
        except Exception:
            return None

    def _restore_terminal(self, saved: tuple[Any, Any] | None) -> None:
        """Restore terminal to original settings.

        Args:
            saved: Tuple returned by _setup_nonblocking_input.
        """
        if saved is None:
            return
        try:
            import termios

            old_settings, fd = saved
            termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
        except Exception:
            pass

    def _read_key_nonblocking(self) -> str | None:
        """Read a single key or escape sequence without blocking.

        Uses os.read() for platform-stable nonblocking reads and prefix-matching
        for escape sequences that may arrive in parts.

        Returns:
            Key character, escape sequence (e.g., "UP", "DOWN"), or None.
        """
        import fcntl
        import os
        import select

        # Known escape sequences (bytes)
        KNOWN_SEQUENCES: dict[bytes, str] = {
            b"\x1b[A": "UP",
            b"\x1b[B": "DOWN",
        }

        fd = sys.stdin.fileno()
        flags = fcntl.fcntl(fd, fcntl.F_GETFL)

        try:
            # Check if data is available
            if not select.select([sys.stdin], [], [], 0)[0]:
                return None

            # Set non-blocking mode
            fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)

            # Read initial bytes using os.read (platform-stable)
            try:
                buf = os.read(fd, 1)
            except (BlockingIOError, OSError):
                return None

            if not buf:
                return None

            # If not an escape character, return immediately
            if buf[0:1] != b"\x1b":
                fcntl.fcntl(fd, fcntl.F_SETFL, flags)  # Restore before return
                return buf.decode("utf-8", errors="ignore")

            # Accumulate bytes for escape sequence detection
            # Use short timeout to wait for remaining bytes
            max_seq_len = max(len(seq) for seq in KNOWN_SEQUENCES)

            while len(buf) < max_seq_len:
                # Check if current buffer matches a complete sequence
                if buf in KNOWN_SEQUENCES:
                    fcntl.fcntl(fd, fcntl.F_SETFL, flags)
                    return KNOWN_SEQUENCES[buf]

                # Check if buffer is a prefix of any known sequence
                is_prefix = any(
                    seq.startswith(buf) and seq != buf for seq in KNOWN_SEQUENCES
                )
                if not is_prefix:
                    # Not a prefix of any known sequence, stop reading
                    break

                # Wait briefly for more bytes (escape sequences arrive quickly)
                if not select.select([sys.stdin], [], [], 0.05)[0]:
                    # Timeout - no more bytes coming
                    break

                try:
                    next_byte = os.read(fd, 1)
                except (BlockingIOError, OSError):
                    break

                if not next_byte:
                    break

                buf += next_byte

            # Restore flags before returning
            fcntl.fcntl(fd, fcntl.F_SETFL, flags)

            # Check for complete sequence match
            if buf in KNOWN_SEQUENCES:
                return KNOWN_SEQUENCES[buf]

            # Standalone ESC (no sequence matched)
            if buf == b"\x1b":
                return "\x1b"

            # Partial or unknown escape sequence - treat as ESC
            if buf.startswith(b"\x1b"):
                return "\x1b"

            # Return first character
            return buf[0:1].decode("utf-8", errors="ignore") or None

        except Exception:
            pass
        finally:
            # Always restore original flags
            with contextlib.suppress(Exception):
                fcntl.fcntl(fd, fcntl.F_SETFL, flags)

        return None

    def _kill_agent(self, registry: AgentRegistry, agent: dict[str, Any]) -> None:
        """Kill an agent and unregister from registry.

        Args:
            registry: AgentRegistry instance.
            agent: Agent data dictionary.
        """
        import signal

        agent_id = agent.get("agent_id")
        pid = agent.get("pid")

        if not pid or pid == "-":
            return

        try:
            os.kill(int(pid), signal.SIGTERM)
            if agent_id:
                registry.unregister(agent_id)
        except ProcessLookupError:
            # Process already dead, cleanup registry
            if agent_id:
                registry.unregister(agent_id)
        except PermissionError:
            # No permission to kill - do not unregister
            pass
        except OSError:
            # Other OS-level errors - do not unregister
            pass
        except (ValueError, TypeError):
            pass  # Invalid PID

    def _create_file_watcher(
        self, registry_dir: Path, change_event: Event
    ) -> Any | None:
        """Create a file watcher for registry directory.

        Args:
            registry_dir: Path to registry directory.
            change_event: Event to signal when changes detected.

        Returns:
            Observer instance if successful, None otherwise.
        """
        try:
            from watchdog.events import FileSystemEventHandler
            from watchdog.observers import Observer

            class RegistryChangeHandler(FileSystemEventHandler):
                def __init__(self, event: Event) -> None:
                    self._event = event

                def on_any_event(self, event: Any) -> None:
                    # Only trigger on JSON file changes
                    if event.src_path.endswith(".json"):
                        self._event.set()

            observer = Observer()
            handler = RegistryChangeHandler(change_event)
            observer.schedule(handler, str(registry_dir), recursive=False)
            observer.start()
            return observer
        except ImportError:
            return None
        except Exception:
            return None

    def _run_rich_tui(
        self,
        registry: AgentRegistry,
        console: Console,
        pkg_version: str,
    ) -> None:
        """Run Rich TUI with event-driven updates.

        Args:
            registry: AgentRegistry instance.
            console: Rich Console instance.
            pkg_version: Package version string.
        """
        from rich.live import Live

        from synapse.commands.renderers.rich_renderer import RichRenderer
        from synapse.settings import get_settings

        renderer = RichRenderer(console=console)

        # Load column configuration from settings
        settings = get_settings()
        list_columns = settings.get_list_columns()

        # Set up non-blocking input for interactive mode
        saved_terminal = self._setup_nonblocking_input()
        interactive = saved_terminal is not None
        selected_row: int | None = None

        # Import terminal jump functionality
        from synapse.terminal_jump import can_jump, jump_to_terminal

        jump_available = can_jump()

        # Track current agents for jump functionality
        current_agents: list[dict[str, Any]] = []

        # Set up file watcher
        change_event = Event()
        observer = self._create_file_watcher(registry.registry_dir, change_event)

        # Force initial update
        change_event.set()

        # Track if display needs refresh (for selection changes)
        needs_refresh = False

        # Kill confirmation state
        kill_confirm_agent: dict[str, Any] | None = None

        # Filter state
        filter_mode = False
        filter_input = ""  # Text being typed in filter mode
        active_filter = ""  # Applied filter

        # Fallback polling (10 seconds) in case file watcher misses events
        last_poll_time = self._time.time()
        poll_interval = 10.0

        # Periodic refresh for elapsed time display (1 second)
        last_elapsed_refresh = self._time.time()
        elapsed_refresh_interval = 1.0

        try:
            with Live(
                console=console,
                auto_refresh=False,
                vertical_overflow="crop",
            ) as live:
                while True:
                    # Check for keyboard input
                    if interactive:
                        key = self._read_key_nonblocking()
                        if key is not None:
                            # Handle filter input mode
                            if filter_mode:
                                if key == "\x1b":  # ESC - cancel filter
                                    filter_mode = False
                                    filter_input = ""
                                elif key in ("\r", "\n"):  # Enter - apply filter
                                    filter_mode = False
                                    active_filter = filter_input
                                    filter_input = ""
                                    selected_row = None
                                elif key in ("\x7f", "\b"):  # Backspace
                                    filter_input = filter_input[:-1]
                                elif len(key) == 1 and key.isprintable():
                                    filter_input += key
                                needs_refresh = True

                            # Handle kill confirmation mode
                            elif kill_confirm_agent is not None:
                                if key in ("y", "Y"):
                                    self._kill_agent(registry, kill_confirm_agent)
                                    selected_row = None
                                    kill_confirm_agent = None
                                    change_event.set()
                                    needs_refresh = True
                                elif key in ("n", "N", "\x1b"):
                                    kill_confirm_agent = None
                                    needs_refresh = True

                            # Normal mode - quit
                            elif key in ("q", "Q"):
                                break

                            # Normal mode - enter filter
                            elif key == "/":
                                filter_mode = True
                                filter_input = active_filter
                                needs_refresh = True

                            # Normal mode - ESC clears filter or selection
                            elif key == "\x1b":
                                if active_filter:
                                    active_filter = ""
                                elif selected_row is not None:
                                    selected_row = None
                                needs_refresh = True

                            # Normal mode - number keys for selection
                            elif key.isdigit() and key != "0":
                                selected_row = int(key)
                                needs_refresh = True

                            # Normal mode - arrow navigation
                            elif key in ("UP", "k", "h") and current_agents:
                                if selected_row is None:
                                    selected_row = len(current_agents)
                                elif selected_row > 1:
                                    selected_row -= 1
                                needs_refresh = True

                            elif key in ("DOWN", "j", "l") and current_agents:
                                if selected_row is None:
                                    selected_row = 1
                                elif selected_row < len(current_agents):
                                    selected_row += 1
                                needs_refresh = True

                            # Normal mode - actions requiring valid selection
                            elif selected_row is not None and 1 <= selected_row <= len(
                                current_agents
                            ):
                                selected_agent = current_agents[selected_row - 1]

                                # Terminal jump
                                if key in ("\r", "\n") and jump_available:
                                    jump_to_terminal(selected_agent)

                                # Kill confirmation
                                elif key == "K":
                                    kill_confirm_agent = selected_agent
                                    needs_refresh = True

                    # Periodic elapsed time refresh (1s interval)
                    current_time = self._time.time()
                    if current_time - last_elapsed_refresh >= elapsed_refresh_interval:
                        needs_refresh = True
                        last_elapsed_refresh = current_time

                    # Fallback polling: trigger update every poll_interval seconds
                    if current_time - last_poll_time >= poll_interval:
                        change_event.set()
                        last_poll_time = current_time

                    # Update display when file changes or selection changes
                    if change_event.is_set() or needs_refresh:
                        change_event.clear()
                        needs_refresh = False

                        # Use active_filter for filtering
                        current_filter = active_filter if active_filter else None
                        agents, stale_locks, show_file_safety = self._get_agent_data(
                            registry, current_filter
                        )

                        # Update current_agents for terminal jump
                        current_agents = agents

                        # Validate selected_row against current agent count
                        if selected_row is not None and selected_row > len(agents):
                            selected_row = None

                        # Clear kill confirmation if agent no longer exists
                        if kill_confirm_agent:
                            agent_ids = [a.get("agent_id") for a in agents]
                            if kill_confirm_agent.get("agent_id") not in agent_ids:
                                kill_confirm_agent = None

                        timestamp = self._time.strftime("%Y-%m-%d %H:%M:%S")

                        # Show filter_input while in filter mode, active_filter otherwise
                        display_filter = filter_input if filter_mode else active_filter

                        display = renderer.render_display(
                            agents=agents,
                            version=pkg_version,
                            timestamp=timestamp,
                            show_file_safety=show_file_safety,
                            stale_locks=stale_locks,
                            interactive=interactive,
                            selected_row=selected_row,
                            jump_available=jump_available,
                            kill_confirm_agent=kill_confirm_agent,
                            filter_mode=filter_mode,
                            filter_text=display_filter,
                            columns=list_columns,
                        )

                        live.update(display, refresh=True)

                    # Small sleep to prevent CPU spinning
                    self._time.sleep(0.05)
        except KeyboardInterrupt:
            pass
        finally:
            if observer:
                observer.stop()
                observer.join()
            self._restore_terminal(saved_terminal)
            console.print("\n[dim]Exiting...[/dim]")
            raise SystemExit(0)

    # Fields exposed in JSON output (excludes internal-only keys)
    _JSON_FIELDS = (
        "agent_id",
        "agent_type",
        "name",
        "role",
        "skill_set",
        "port",
        "status",
        "pid",
        "working_dir",
        "endpoint",
        "transport",
        "current_task_preview",
        "task_received_at",
    )

    def run_json(self, args: argparse.Namespace) -> None:
        """Output agent list as JSON array for programmatic consumption."""
        registry = self._registry_factory()
        agents, _stale_locks, show_file_safety = self._get_agent_data(registry)

        result = []
        for agent in agents:
            entry = {k: agent.get(k) for k in self._JSON_FIELDS}
            # Use full path for working_dir in JSON (short name is for TUI only)
            entry["working_dir"] = agent.get("working_dir_full") or agent.get(
                "working_dir"
            )
            # editing_file is only present when file safety is enabled
            if show_file_safety:
                entry["editing_file"] = agent.get("editing_file")
            result.append(entry)

        self._print(json.dumps(result))

    def run(self, args: argparse.Namespace) -> None:
        """List running agents with Rich TUI."""
        registry = self._registry_factory()

        # Check if stdout is a TTY
        if not sys.stdout.isatty():
            # Non-interactive mode: single output
            agents, _, show_file_safety = self._get_agent_data(registry)
            if not agents:
                lines = ["No agents running.", "", "Port ranges:"]
                for agent_type, (start, end) in sorted(PORT_RANGES.items()):
                    lines.append(f"  {agent_type}: {start}-{end}")
                self._print("\n".join(lines))
            else:
                # Simple table output - build columns dynamically
                columns = [
                    ("TYPE", 10),
                    ("NAME", 16),
                    ("ROLE", 16),
                    ("SKILL_SET", 16),
                    ("ID", 24),
                    ("PORT", 8),
                    ("STATUS", 12),
                    ("TRANSPORT", 10),
                    ("WORKING_DIR", 20),
                ]
                if show_file_safety:
                    columns.append(("EDITING_FILE", 20))
                columns.extend([("CURRENT", 35), ("PID", 8), ("ENDPOINT", 0)])

                header = " ".join(
                    f"{name:<{width}}" if width else name for name, width in columns
                )
                self._print(header)
                self._print("-" * len(header))

                for agent in agents:
                    values = [
                        (agent["agent_type"], 10),
                        (agent.get("name") or "-", 16),
                        (agent.get("role") or "-", 16),
                        (agent.get("skill_set") or "-", 16),
                        (agent.get("agent_id", "-"), 24),
                        (agent["port"], 8),
                        (agent["status"], 12),
                        (str(agent.get("transport") or "-"), 10),
                        (agent.get("working_dir") or "-", 20),
                    ]
                    if show_file_safety:
                        values.append((agent.get("editing_file") or "-", 20))
                    # Format CURRENT with elapsed time
                    preview = agent.get("current_task_preview") or "-"
                    received_at = agent.get("task_received_at")
                    if preview != "-" and isinstance(received_at, (int, float)):
                        import time as _time

                        from synapse.commands.renderers.rich_renderer import (
                            format_elapsed,
                        )

                        elapsed = _time.time() - received_at
                        preview = f"{preview} ({format_elapsed(elapsed)})"
                    values.extend(
                        [
                            (preview, 35),
                            (agent["pid"], 8),
                            (agent["endpoint"], 0),
                        ]
                    )
                    row = " ".join(
                        f"{val:<{width}}" if width else str(val)
                        for val, width in values
                    )
                    self._print(row)
            return

        # Get version for display
        try:
            from importlib.metadata import version

            pkg_version = version("synapse-a2a")
        except Exception:
            pkg_version = "unknown"

        from rich.console import Console

        console = Console()
        self._run_rich_tui(registry, console, pkg_version)
