#!/usr/bin/env python3
"""portui - A TUI for monitoring and managing local listening ports."""

from __future__ import annotations

import dataclasses
import ipaddress
import json
import socket
from pathlib import Path
from typing import Iterator

import psutil
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.screen import Screen
from textual.timer import Timer
from textual.widgets import (
    Button,
    Checkbox,
    DataTable,
    Footer,
    Header,
    Input,
    Label,
    MarkdownViewer,
)
from textual.widgets._data_table import RowDoesNotExist
from textual.worker import get_current_worker


@dataclasses.dataclass(frozen=True)
class PortInfo:
    """Information about a listening port."""

    port: int
    ip: str
    protocol: str
    pid: int
    process_name: str
    username: str
    state: str
    cmdline: str
    ppid: int = 0  # Parent process ID for tree view


class PortDataCollector:
    """Collects port and process information using psutil."""

    @staticmethod
    def get_listening_ports() -> list[PortInfo]:
        """Get all listening ports with process information."""
        ports: list[PortInfo] = []
        seen: set[tuple[str, int, int, str]] = set()  # (ip, port, pid, protocol)

        for proc in psutil.process_iter(["pid", "name", "username", "cmdline", "ppid"]):
            try:
                pinfo = proc.info
                if not pinfo["pid"]:
                    continue

                connections = proc.connections(kind="inet")
                for conn in connections:
                    local_addr = conn.laddr
                    if not local_addr:
                        continue

                    # TCP listening sockets have CONN_LISTEN status
                    # UDP sockets have None status but are "listening" if bound
                    is_tcp_listen = (
                        conn.type == socket.SOCK_STREAM
                        and conn.status == psutil.CONN_LISTEN
                    )
                    is_udp_bound = conn.type == socket.SOCK_DGRAM

                    if not (is_tcp_listen or is_udp_bound):
                        continue

                    ip = local_addr.ip
                    port = local_addr.port
                    protocol = "TCP" if conn.type == socket.SOCK_STREAM else "UDP"
                    key = (ip, port, pinfo["pid"], protocol)

                    if key in seen:
                        continue
                    seen.add(key)

                    cmdline = " ".join(pinfo["cmdline"] or [pinfo["name"] or ""])
                    if len(cmdline) > 60:
                        cmdline = cmdline[:57] + "..."

                    port_info = PortInfo(
                        port=port,
                        ip=ip,
                        protocol=protocol,
                        pid=pinfo["pid"],
                        process_name=pinfo["name"] or "[unknown]",
                        username=pinfo["username"] or "[unknown]",
                        state=conn.status or "LISTEN"
                        if protocol == "UDP"
                        else conn.status,
                        cmdline=cmdline,
                        ppid=pinfo.get("ppid") or 0,
                    )
                    ports.append(port_info)

            except (psutil.NoSuchProcess, psutil.AccessDenied):
                continue

        return sorted(ports, key=lambda p: (p.port, p.ip))

    @staticmethod
    def kill_process(pid: int, force: bool = False) -> tuple[bool, str]:
        """Kill a process. Returns (success, message)."""
        try:
            proc = psutil.Process(pid)
            name = proc.name()

            if force:
                proc.kill()
                return True, f"Force killed {name} (PID {pid})"
            else:
                proc.terminate()
                return True, f"Terminated {name} (PID {pid})"
        except psutil.NoSuchProcess:
            return False, f"Process {pid} no longer exists"
        except psutil.AccessDenied:
            return False, f"Permission denied to kill process {pid}"
        except Exception as e:
            return False, f"Error killing process {pid}: {e}"


class ColumnConfigScreen(Screen):
    """Screen for configuring visible columns."""

    def __init__(
        self, col_visible: tuple[bool, ...] | list[bool], names: list[str]
    ) -> None:
        super().__init__()
        self.col_visible = list(col_visible)
        self.names = names

    def compose(self) -> ComposeResult:
        """Compose the screen."""
        with Vertical(id="dialog"):
            yield Label("Toggle Columns (space to toggle, enter to apply):")
            for name, vis in zip(self.names, self.col_visible):
                checkbox = Checkbox(name, value=vis)
                checkbox.id = f"col-{name}"
                yield checkbox
            with Horizontal():
                yield Button("Apply", id="apply", variant="primary")
                yield Button("Cancel", id="cancel")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Handle button presses."""
        if event.button.id == "apply":
            for i, name in enumerate(self.names):
                checkbox = self.query_one(f"#col-{name}", Checkbox)
                self.col_visible[i] = checkbox.value
            self.dismiss(self.col_visible)
        else:
            self.dismiss(None)

    def on_key(self, event) -> None:
        """Handle key presses."""
        if event.key == "escape":
            self.dismiss(None)


class IntervalScreen(Screen):
    """Screen for selecting refresh interval."""

    INTERVALS = [1, 2, 3, 5, 10, 30, 60]

    def __init__(self, current: int) -> None:
        super().__init__()
        self.current = current

    def compose(self) -> ComposeResult:
        """Compose the screen."""
        with Vertical(id="dialog"):
            yield Label("Select refresh interval (seconds):")
            for interval in self.INTERVALS:
                variant = "primary" if interval == self.current else "default"
                label = (
                    f"{interval}s (current)"
                    if interval == self.current
                    else f"{interval}s"
                )
                yield Button(label, id=f"int-{interval}", variant=variant)
            yield Button("Cancel", id="cancel")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Handle button presses."""
        if event.button.id and event.button.id.startswith("int-"):
            interval = int(event.button.id.split("-")[1])
            self.dismiss(interval)
        else:
            self.dismiss(None)

    def on_key(self, event) -> None:
        """Handle key presses."""
        if event.key == "escape":
            self.dismiss(None)


class PortDetailScreen(Screen):
    """Overlay screen showing full details of a port/process."""

    BINDINGS = [
        ("escape", "dismiss", "Close"),
    ]

    def __init__(self, port: PortInfo) -> None:
        super().__init__()
        self.port = port

    def compose(self) -> ComposeResult:
        """Compose the screen."""
        # Fetch full command line from psutil (not truncated)
        full_cmdline = self.port.cmdline
        try:
            proc = psutil.Process(self.port.pid)
            parts = proc.cmdline()
            if parts:
                full_cmdline = " ".join(parts)
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass

        with Vertical(id="detail-container"):
            yield Label(f"Process Details: {self.port.process_name}", id="title")
            yield Label(f"PID:       {self.port.pid}")
            yield Label(f"PPID:      {self.port.ppid}")
            yield Label(f"Port:      {self.port.port}/{self.port.protocol}")
            yield Label(f"IP:        {self.port.ip}")
            yield Label(f"User:      {self.port.username}")
            yield Label(f"State:     {self.port.state}")
            yield Label(f"Command:   {full_cmdline}", id="detail-command")
            yield Label("")
            yield Label("Press Escape to close", id="detail-hint")

    def on_key(self, event) -> None:
        """Handle key press."""
        if event.key in ("escape",):
            self.dismiss()


class KillDialogScreen(Screen):
    """Screen for confirming process kill."""

    def __init__(self, port: PortInfo) -> None:
        super().__init__()
        self.port = port

    def compose(self) -> ComposeResult:
        """Compose the screen."""
        with Vertical(id="dialog"):
            yield Label(f"Kill Process: {self.port.process_name}", id="title")
            yield Label(f"PID: {self.port.pid}")
            yield Label(f"Port: {self.port.port}/{self.port.protocol}")
            yield Label(f"Command: {self.port.cmdline}")
            yield Label("")
            yield Label("Choose kill method:")
            with Horizontal():
                yield Button("Graceful (SIGTERM)", id="graceful", variant="primary")
                yield Button("Force (SIGKILL)", id="force", variant="error")
                yield Button("Cancel", id="cancel")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Handle button presses."""
        if event.button.id == "graceful":
            self.dismiss((self.port, False))
        elif event.button.id == "force":
            self.dismiss((self.port, True))
        else:
            self.dismiss(None)

    def on_key(self, event) -> None:
        """Handle key presses."""
        if event.key == "escape":
            self.dismiss(None)


class HelpScreen(Screen):
    """Help screen with keyboard shortcuts."""

    BINDINGS = [
        ("escape,q", "dismiss", "Close"),
        ("?", "dismiss", "Close"),
    ]

    HELP_TEXT = """
# Keyboard Shortcuts

## Navigation
- ↑/↓ or j/k - Navigate table rows
- Enter - Select row

## Actions
- / - Focus filter input (Escape to clear and exit)
- c - Toggle column configuration
- i - Set refresh interval
- x - Kill selected process
- r - Manual refresh
- p - Pause/resume auto-refresh
- s - Cycle sort column
- t - Toggle tree view (group by process hierarchy)
- ? - Show this help
- q - Quit

## Kill Dialog
- Select a process with ↑/↓ or j/k, then press x
- Choose "Graceful" for SIGTERM (clean shutdown)
- Choose "Force" for SIGKILL (immediate termination)
- Press Escape or "Cancel" to abort

## Tree View
- Press 't' to toggle tree view in the main table
- Child processes are indented under their parents (htop-style)
- Works with filtering and sorting

## Tips
- Auto-refresh pauses while you're typing in the filter
- The status bar shows current refresh state and port count
- Use column config to hide/show columns you don't need
- Click column headers to sort
"""

    def compose(self) -> ComposeResult:
        """Compose the screen."""
        with Vertical(id="help-container"):
            yield MarkdownViewer(self.HELP_TEXT, show_table_of_contents=False)
            yield Button("Close (Esc)", id="close", variant="primary")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Handle button press."""
        if event.button.id == "close":
            self.dismiss()

    def on_key(self, event) -> None:
        """Handle key press."""
        if event.key in ("escape", "q", "?"):
            self.dismiss()


class PortUIApp(App):
    """Main TUI application for port monitoring."""

    CSS = """
    Screen {
        align: center middle;
    }

    #toolbar {
        display: none;
        height: auto;
        background: $surface;
        padding: 0 1;
        color: $text;
    }

    #toolbar.filter-active {
        display: block;
    }

    #filter-input {
        width: 1fr;
        background: $surface-darken-1;
        border: solid $warning;
        margin: 0 0 0 1;
    }

    #filter-label {
        padding: 1 0 0 0;
    }

    #filter-mode-indicator {
        display: none;
        color: $text;
        background: $warning;
        text-style: bold;
        padding: 1 1 0 0;
    }

    #toolbar.filter-active #filter-mode-indicator {
        display: block;
    }

    #port-table {
        height: 1fr;
        border: solid $primary;
    }

    #port-table:focus {
        border: solid $accent;
    }

    #port-table > .datatable--cursor {
        background: $accent 50%;
    }

    #status-bar {
        height: 1;
        background: $surface-darken-1;
        color: $text;
        padding: 0 1;
        text-style: dim;
    }

    #status-bar.paused {
        background: $warning;
        color: $text;
        text-style: bold;
    }

    #detail-container {
        width: 80%;
        max-width: 100;
        height: auto;
        border: solid $accent;
        background: $surface;
        padding: 1 2;
    }

    #detail-container Label {
        margin: 0;
    }

    #detail-container #title {
        text-style: bold;
        color: $accent;
        margin: 0 0 1 0;
    }

    #detail-container #detail-command {
        width: 100%;
    }

    #detail-container #detail-hint {
        text-style: dim;
    }

    #dialog {
        width: 60;
        height: auto;
        border: solid $primary;
        background: $surface;
        padding: 1 2;
    }

    #dialog > Label {
        margin: 0 0 1 0;
    }

    #dialog #title {
        text-style: bold;
        color: $warning;
    }

    #help-container {
        width: 80;
        height: 30;
        border: solid $primary;
        background: $surface;
        padding: 1 2;
    }

    #help-container MarkdownViewer {
        height: 1fr;
    }

    #help-container Button {
        margin: 1 0 0 0;
        width: 100%;
    }

    Checkbox {
        margin: 0 0 1 0;
    }

    Horizontal Button {
        margin: 0 1 0 0;
    }

    """

    BINDINGS = [
        ("q", "quit", "Quit"),
        ("r", "refresh", "Refresh"),
        ("p", "toggle_pause", "Pause/Resume"),
        ("slash", "focus_filter", "Filter"),
        ("c", "toggle_columns", "Columns"),
        ("i", "set_interval", "Interval"),
        ("x", "kill_selected", "Kill"),
        ("s", "cycle_sort", "Sort"),
        ("h", "cycle_protocol", "Proto"),
        ("t", "show_tree", "Tree"),
        ("?", "show_help", "Help"),
        ("j", "cursor_down", "Down"),
        ("k", "cursor_up", "Up"),
        ("up", "cursor_up", "Up"),
        ("down", "cursor_down", "Down"),
    ]

    COLUMN_NAMES = ["Port", "IP", "Proto", "Process", "PID", "User", "State", "Command"]
    COLUMN_KEYS = ["port", "ip", "proto", "process", "pid", "user", "state", "cmdline"]

    ports: reactive[list[PortInfo]] = reactive([])
    auto_refresh: reactive[bool] = reactive(True)
    refresh_interval: reactive[int] = reactive(3)
    is_refreshing: reactive[bool] = reactive(False)
    filter_text: reactive[str] = reactive("")
    user_interacting: reactive[bool] = reactive(False)
    visible_columns: reactive[tuple[bool, ...]] = reactive((True,) * 8)
    _refresh_timer: Timer | None = None
    _sort_column: str | None = None
    _sort_ascending: bool = True
    _tree_mode: bool = False
    _protocol_filter: str = "both"  # "both", "tcp", "udp"
    _config_path: Path = Path.home() / ".config" / "portui" / "config.json"

    def compose(self) -> ComposeResult:
        """Compose the UI."""
        yield Header(show_clock=True)
        yield DataTable(id="port-table")
        with Horizontal(id="toolbar"):
            yield Label("Filter: ", id="filter-label")
            yield Label(" FILTER MODE ", id="filter-mode-indicator")
            yield Input(id="filter-input", placeholder="press / to filter...")
        yield Label(id="status-bar")
        yield Footer()

    def on_mount(self) -> None:
        """Initialize on mount."""
        self._load_config()
        table = self.query_one("#port-table", DataTable)
        self._update_table_columns()
        table.cursor_type = "row"
        table.focus()
        self._start_refresh_timer()
        self.action_refresh()

    def on_key(self, event) -> None:
        """Handle key presses for filter mode."""
        filter_input = self.query_one("#filter-input", Input)
        if event.key == "slash" and not filter_input.has_focus:
            filter_input.focus()
            self.user_interacting = True
            event.stop()
        elif event.key == "escape" and filter_input.has_focus:
            filter_input.value = ""
            self.filter_text = ""
            self._apply_filter()
            self._update_status()
            self.query_one("#toolbar").remove_class("filter-active")
            table = self.query_one("#port-table", DataTable)
            table.focus()
            self.user_interacting = False
            event.stop()

    def _load_config(self) -> None:
        """Load configuration from file."""
        try:
            if self._config_path.exists():
                with open(self._config_path) as f:
                    config = json.load(f)
                if "refresh_interval" in config:
                    self.refresh_interval = config["refresh_interval"]
                if "visible_columns" in config:
                    self.visible_columns = tuple(config["visible_columns"])
                if "auto_refresh" in config:
                    self.auto_refresh = config["auto_refresh"]
        except (json.JSONDecodeError, KeyError, TypeError):
            pass  # Use defaults on error

    def _save_config(self) -> None:
        """Save configuration to file."""
        try:
            self._config_path.parent.mkdir(parents=True, exist_ok=True)
            config = {
                "refresh_interval": self.refresh_interval,
                "visible_columns": list(self.visible_columns),
                "auto_refresh": self.auto_refresh,
            }
            with open(self._config_path, "w") as f:
                json.dump(config, f, indent=2)
        except OSError:
            pass  # Silently fail if we can't save config

    def on_unmount(self) -> None:
        """Save config on exit."""
        self._save_config()

    def _start_refresh_timer(self) -> None:
        """Start or restart the refresh timer."""
        if self._refresh_timer:
            self._refresh_timer.stop()
        self._refresh_timer = self.set_interval(
            self.refresh_interval, self._scheduled_refresh
        )

    def _update_table_columns(self) -> None:
        """Update table columns based on visibility."""
        table = self.query_one("#port-table", DataTable)
        table.clear(columns=True)

        for name, visible in zip(self.COLUMN_NAMES, self.visible_columns):
            if visible:
                table.add_column(name)

    def watch_ports(self, ports: list[PortInfo]) -> None:
        """Update table when ports data changes."""
        self._apply_filter()
        self._update_status()

    def watch_auto_refresh(self, auto_refresh: bool) -> None:
        """Update status when auto-refresh changes."""
        self._update_status()

    def watch_visible_columns(self, visible_columns: list[bool]) -> None:
        """Update table when column visibility changes."""
        self._update_table_columns()
        self._apply_filter()

    def _update_status(self) -> None:
        """Update the status bar."""
        status = self.query_one("#status-bar", Label)
        filter_info = f" [filter: {self.filter_text}]" if self.filter_text else ""
        sort_info = (
            f" [sort: {self._sort_column} {'↑' if self._sort_ascending else '↓'}]"
            if self._sort_column
            else ""
        )
        proto_info = (
            f" [{self._protocol_filter.upper()}]"
            if self._protocol_filter != "both"
            else ""
        )
        tree_info = " [TREE]" if self._tree_mode else ""
        if self.auto_refresh:
            refresh_status = f"{self.refresh_interval}s [ON]"
            status.remove_class("paused")
        else:
            refresh_status = "PAUSED — press p to resume"
            status.add_class("paused")
        status_text = f"{len(self.ports)} ports{filter_info}{sort_info}{proto_info}{tree_info} • {refresh_status} • jk nav • / filter • h proto • x kill • t tree • s sort • q quit"
        status.update(status_text)

    def _scheduled_refresh(self) -> None:
        """Called by the interval timer."""
        if self.auto_refresh and not self.is_refreshing and not self.user_interacting:
            self.action_refresh()

    def action_toggle_pause(self) -> None:
        """Toggle auto-refresh pause state."""
        self.auto_refresh = not self.auto_refresh

    def action_cursor_down(self) -> None:
        """Move cursor down in the table."""
        table = self.query_one("#port-table", DataTable)
        if table.cursor_row is not None and table.cursor_row < table.row_count - 1:
            table.cursor_coordinate = (table.cursor_row + 1, 0)

    def action_cursor_up(self) -> None:
        """Move cursor up in the table."""
        table = self.query_one("#port-table", DataTable)
        if table.cursor_row is not None and table.cursor_row > 0:
            table.cursor_coordinate = (table.cursor_row - 1, 0)

    def action_cycle_sort(self) -> None:
        """Cycle through sort columns."""
        columns = ["port", "ip", "process", "pid", "user", "state", "protocol"]
        if self._sort_column is None:
            self._sort_column = columns[0]
            self._sort_ascending = True
        else:
            idx = (
                columns.index(self._sort_column) if self._sort_column in columns else -1
            )
            if idx == len(columns) - 1:
                self._sort_column = None
            else:
                self._sort_column = columns[idx + 1]
        self._apply_sort()
        self._apply_filter()
        self._update_status()

    def _apply_sort(self) -> None:
        """Sort ports by current sort column."""
        if self._sort_column is None:
            self.ports = sorted(self.ports, key=lambda p: p.port)
        elif self._sort_column == "port":
            self.ports = sorted(
                self.ports, key=lambda p: p.port, reverse=not self._sort_ascending
            )
        elif self._sort_column == "ip":
            # Use ipaddress for proper numerical IP sorting, fall back to string for invalid IPs
            def ip_sort_key(p):
                try:
                    addr = ipaddress.ip_address(p.ip)
                    # Sort by version first (IPv4=4, IPv6=6), then by address value
                    return (0, addr.version, addr)
                except (ValueError, TypeError):
                    return (1, 0, p.ip or "")

            self.ports = sorted(
                self.ports, key=ip_sort_key, reverse=not self._sort_ascending
            )
        elif self._sort_column == "process":
            self.ports = sorted(
                self.ports,
                key=lambda p: p.process_name.lower(),
                reverse=not self._sort_ascending,
            )
        elif self._sort_column == "pid":
            self.ports = sorted(
                self.ports, key=lambda p: p.pid, reverse=not self._sort_ascending
            )
        elif self._sort_column == "user":
            self.ports = sorted(
                self.ports,
                key=lambda p: p.username.lower(),
                reverse=not self._sort_ascending,
            )
        elif self._sort_column == "state":
            self.ports = sorted(
                self.ports,
                key=lambda p: (p.state or "").lower(),
                reverse=not self._sort_ascending,
            )
        elif self._sort_column == "protocol":
            self.ports = sorted(
                self.ports, key=lambda p: p.protocol, reverse=not self._sort_ascending
            )

    def action_focus_filter(self) -> None:
        """Focus the filter input."""
        self.query_one("#filter-input", Input).focus()
        self.user_interacting = True
        self.query_one("#toolbar").add_class("filter-active")

    def action_toggle_columns(self) -> None:
        """Toggle column visibility panel."""
        self.push_screen(
            ColumnConfigScreen(self.visible_columns, self.COLUMN_NAMES),
            self._on_columns_set,
        )

    def _on_columns_set(self, result: list[bool] | None) -> None:
        """Handle column config result."""
        if result is not None:
            self.visible_columns = tuple(result)

    def action_set_interval(self) -> None:
        """Open interval configuration."""
        self.push_screen(IntervalScreen(self.refresh_interval), self._on_interval_set)

    def _on_interval_set(self, result: int | None) -> None:
        """Handle interval selection."""
        if result is not None:
            self.refresh_interval = result
            self._start_refresh_timer()
            self._update_status()

    def _get_selected_port(self) -> PortInfo | None:
        """Get the PortInfo for the currently selected row, or None."""
        table = self.query_one("#port-table", DataTable)
        if table.cursor_row is None or table.row_count == 0:
            return None

        try:
            row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
        except (IndexError, KeyError):
            return None

        key_str = str(row_key.value) if hasattr(row_key, "value") else str(row_key)

        if key_str.startswith("ancestor-"):
            return None

        parts = key_str.split("-", 2)
        if len(parts) < 3:
            return None
        try:
            target_pid = int(parts[0])
            target_port = int(parts[1])
        except ValueError:
            return None
        target_ip = parts[2]

        for p in self.ports:
            if p.pid == target_pid and p.port == target_port and p.ip == target_ip:
                return p
        return None

    def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
        """Show full details when a row is selected (Enter)."""
        port = self._get_selected_port()
        if port is None:
            return
        self.push_screen(PortDetailScreen(port))

    def action_kill_selected(self) -> None:
        """Kill the selected process."""
        port = self._get_selected_port()
        if port is None:
            self.notify("No process selected", severity="warning")
            return
        self.push_screen(KillDialogScreen(port), self._on_kill_result)

    def _get_filtered_ports(self) -> list[PortInfo]:
        """Get filtered list of ports."""
        return [p for p in self.ports if self._matches_filter(p)]

    def _on_kill_result(self, result: tuple[PortInfo, bool] | None) -> None:
        """Handle kill dialog result."""
        if result is None:
            return

        port, force = result
        success, message = PortDataCollector.kill_process(port.pid, force)

        if success:
            self.notify(message, severity="information")
            self.action_refresh()
        else:
            self.notify(message, severity="error")

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

    def action_cycle_protocol(self) -> None:
        """Cycle protocol filter: Both -> TCP -> UDP -> Both."""
        cycle = {"both": "tcp", "tcp": "udp", "udp": "both"}
        self._protocol_filter = cycle[self._protocol_filter]
        self._apply_filter()
        self._update_status()

    def action_show_tree(self) -> None:
        """Toggle inline tree view."""
        self._tree_mode = not self._tree_mode
        self._apply_filter()
        self._update_status()

    def on_input_changed(self, event: Input.Changed) -> None:
        """Handle filter input changes — apply filter immediately."""
        if event.input.id == "filter-input":
            self.filter_text = event.value
            self._apply_filter()
            self._update_status()

    def on_input_blur(self, event: Input.Blur) -> None:
        """Handle filter input blur."""
        if event.input.id == "filter-input":
            self.user_interacting = False
            toolbar = self.query_one("#toolbar")
            toolbar.remove_class("filter-active")

    def on_input_focus(self, event: Input.Focus) -> None:
        """Handle filter input focus."""
        if event.input.id == "filter-input":
            self.user_interacting = True
            toolbar = self.query_one("#toolbar")
            toolbar.add_class("filter-active")

    def _matches_filter(self, port: PortInfo) -> bool:
        """Check if a port matches the current filter text and protocol."""
        if self._protocol_filter == "tcp" and port.protocol != "TCP":
            return False
        if self._protocol_filter == "udp" and port.protocol != "UDP":
            return False
        if not self.filter_text:
            return True
        fl = self.filter_text.lower()
        return (
            fl in str(port.port).lower()
            or fl in port.ip.lower()
            or fl in port.protocol.lower()
            or fl in port.process_name.lower()
            or fl in str(port.pid).lower()
            or fl in port.username.lower()
            or fl in port.state.lower()
            or fl in port.cmdline.lower()
        )

    def _build_tree_rows(
        self,
        filtered: list[PortInfo],
    ) -> list[tuple[PortInfo | None, int | None, str | None, str]]:
        """Build tree-ordered rows with htop-style prefixes.

        Returns list of tuples:
          (port, ancestor_pid, ancestor_name, display_prefix)

        For rows from `filtered`, port is set and ancestor_pid/ancestor_name
        are None.  For synthetic ancestor rows (processes that don't have
        listening ports but are parents of ones that do), port is None and
        ancestor_pid/ancestor_name describe the process.
        """
        # Group filtered ports by PID
        by_pid: dict[int, list[PortInfo]] = {}
        for p in filtered:
            by_pid.setdefault(p.pid, []).append(p)

        pids_in_set = set(by_pid)

        # Walk up the real process tree to find common ancestors.
        # We ask psutil for each ancestor so that unrelated processes
        # sharing a distant init/launchd parent don't get merged.
        ancestor_info: dict[int, tuple[str, int]] = {}  # pid -> (name, ppid)

        def _resolve_ancestors(pid: int, ppid: int) -> int:
            """Walk up from ppid, adding intermediate ancestors.

            Returns the highest ancestor PID added (or ppid if none added).
            Stops when we hit PID 0/1, a cycle, or a dead process.
            """
            cur = ppid
            visited: set[int] = set()
            chain: list[tuple[int, str, int]] = []
            while cur not in pids_in_set and cur > 1 and cur not in visited:
                visited.add(cur)
                try:
                    proc = psutil.Process(cur)
                    name = proc.name()
                    parent_pid = proc.ppid()
                except (psutil.NoSuchProcess, psutil.AccessDenied):
                    break
                chain.append((cur, name, parent_pid))
                cur = parent_pid

            if not chain:
                return ppid

            # Only insert ancestors if the walk ended at a PID already in
            # our set or we found a meaningful grouping (2+ port-PIDs share
            # an ancestor we discovered).  Always insert so the tree shows
            # the real hierarchy.
            for apid, aname, appid in chain:
                ancestor_info[apid] = (aname, appid)
                pids_in_set.add(apid)
            return chain[-1][0]

        # Resolve ancestors for every port-PID
        for pid in list(by_pid):
            ppid = by_pid[pid][0].ppid
            if ppid > 1 and ppid not in pids_in_set:
                _resolve_ancestors(pid, ppid)

        # Build parent -> children mapping among all known PIDs
        children_of: dict[int, list[int]] = {}
        parent_of: dict[int, int] = {}

        for pid in pids_in_set:
            if pid in by_pid:
                ppid = by_pid[pid][0].ppid
            else:
                _, ppid = ancestor_info.get(pid, ("", 0))
            if ppid in pids_in_set and ppid != pid:
                children_of.setdefault(ppid, []).append(pid)
                parent_of[pid] = ppid

        # Build a sort key function that respects the current sort column.
        # For ancestor PIDs (no ports), we derive the key from the first
        # port-holding descendant so the subtree sorts as a unit.
        def _pid_sort_key(pid: int) -> object:
            """Return a sort key for a PID based on current sort settings."""
            is_ancestor = pid not in by_pid
            if not is_ancestor:
                p = by_pid[pid][0]
            else:
                # Ancestor — find first port-holding descendant for most
                # columns, but use the ancestor's own name for process sort.
                p = _first_port_descendant(pid)
                if p is None:
                    return (0,)  # fallback

            if self._sort_column == "process":
                if is_ancestor:
                    aname, _ = ancestor_info[pid]
                    return aname.lower()
                return p.process_name.lower()
            elif self._sort_column == "port":
                return p.port
            elif self._sort_column == "ip":
                try:
                    addr = ipaddress.ip_address(p.ip)
                    return (0, addr.version, addr)
                except (ValueError, TypeError):
                    return (1, 0, p.ip or "")
            elif self._sort_column == "pid":
                return pid if is_ancestor else p.pid
            elif self._sort_column == "user":
                return p.username.lower()
            elif self._sort_column == "state":
                return (p.state or "").lower()
            elif self._sort_column == "protocol":
                return p.protocol
            else:
                return p.port  # default

        def _first_port_descendant(pid: int) -> PortInfo | None:
            """Find first port-holding descendant via DFS."""
            if pid in by_pid:
                return by_pid[pid][0]
            for child in children_of.get(pid, []):
                result = _first_port_descendant(child)
                if result is not None:
                    return result
            return None

        reverse = not self._sort_ascending if self._sort_column else False

        # Sort children using the sort key
        for ppid in children_of:
            children_of[ppid].sort(key=_pid_sort_key, reverse=reverse)

        # Remove ancestors that have no port-holding descendants
        # (i.e. they were added but none of their subtree has ports)
        def _has_port_descendant(pid: int) -> bool:
            if pid in by_pid:
                return True
            for child in children_of.get(pid, []):
                if _has_port_descendant(child):
                    return True
            return False

        # Clean up ancestors with no port descendants
        stale = [pid for pid in ancestor_info if not _has_port_descendant(pid)]
        for pid in stale:
            pids_in_set.discard(pid)
            del ancestor_info[pid]
            if pid in children_of:
                del children_of[pid]
            if pid in parent_of:
                parent_pid = parent_of.pop(pid)
                if parent_pid in children_of:
                    children_of[parent_pid] = [
                        c for c in children_of[parent_pid] if c != pid
                    ]

        # Find root PIDs (no parent in the set), sorted by current sort key
        roots = sorted(
            (pid for pid in pids_in_set if pid not in parent_of),
            key=_pid_sort_key,
            reverse=reverse,
        )

        # DFS to produce tree-ordered output
        result: list[tuple[PortInfo | None, int | None, str | None, str]] = []

        def _walk(pid: int, prefix: str, is_last: bool, is_root: bool) -> None:
            kids = children_of.get(pid, [])

            if is_root:
                connector = ""
                child_prefix = ""
            else:
                connector = prefix + ("└── " if is_last else "├── ")
                child_prefix = prefix + ("    " if is_last else "│   ")

            if pid in by_pid:
                # Real port-holding process
                ports = by_pid[pid]
                for i, port in enumerate(ports):
                    if i == 0:
                        result.append((port, None, None, connector))
                    else:
                        result.append((port, None, None, child_prefix))
            else:
                # Synthetic ancestor row
                aname, _ = ancestor_info[pid]
                result.append((None, pid, aname, connector))

            # Recurse into children
            for j, child_pid in enumerate(kids):
                _walk(child_pid, child_prefix, j == len(kids) - 1, False)

        for i, root_pid in enumerate(roots):
            _walk(root_pid, "", i == len(roots) - 1, True)

        return result

    def _apply_filter(self) -> None:
        """Apply filter to the table, preserving cursor selection."""
        table = self.query_one("#port-table", DataTable)

        # Save current selection before clearing
        saved_key = None
        if table.cursor_row is not None and table.row_count > 0:
            try:
                saved_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
            except (IndexError, KeyError):
                pass

        table.clear()

        filtered = [p for p in self.ports if self._matches_filter(p)]

        if self._tree_mode:
            tree_rows = self._build_tree_rows(filtered)
            for port, ancestor_pid, ancestor_name, prefix in tree_rows:
                if port is not None:
                    row_data = [
                        str(port.port),
                        port.ip,
                        port.protocol,
                        prefix + port.process_name,
                        str(port.pid),
                        port.username,
                        port.state,
                        port.cmdline,
                    ]
                    row_key = f"{port.pid}-{port.port}-{port.ip}"
                else:
                    # Synthetic ancestor row — no port info
                    row_data = [
                        "",
                        "",
                        "",
                        prefix + (ancestor_name or ""),
                        str(ancestor_pid or ""),
                        "",
                        "",
                        "",
                    ]
                    row_key = f"ancestor-{ancestor_pid}"
                visible_data = [
                    v for v, vis in zip(row_data, self.visible_columns) if vis
                ]
                table.add_row(*visible_data, key=row_key)
        else:
            for port in filtered:
                row_data = [
                    str(port.port),
                    port.ip,
                    port.protocol,
                    port.process_name,
                    str(port.pid),
                    port.username,
                    port.state,
                    port.cmdline,
                ]
                visible_data = [
                    v for v, vis in zip(row_data, self.visible_columns) if vis
                ]
                table.add_row(*visible_data, key=f"{port.pid}-{port.port}-{port.ip}")

        # Restore cursor position if the row still exists
        if saved_key is not None and table.row_count > 0:
            try:
                new_row_index = table.get_row_index(saved_key)
                table.cursor_coordinate = (new_row_index, 0)
            except (KeyError, IndexError, RowDoesNotExist):
                pass  # Row no longer exists, cursor stays at default position

    def action_refresh(self) -> None:
        """Refresh the port list."""
        if self.is_refreshing:
            return
        self.is_refreshing = True
        self.run_worker(self._fetch_ports, thread=True)

    def _fetch_ports(self) -> None:
        """Fetch ports in worker thread."""
        worker = get_current_worker()
        if worker.is_cancelled:
            return

        ports = PortDataCollector.get_listening_ports()
        self.call_from_thread(self._update_ports, ports)

    def _update_ports(self, ports: list[PortInfo]) -> None:
        """Update ports from main thread."""
        self.ports = ports
        self._apply_sort()
        self.is_refreshing = False


def main() -> None:
    """Entry point."""
    app = PortUIApp()
    app.run()


if __name__ == "__main__":
    main()
