"""Implements the main TUI application for managing USBIPD devices on Windows.

Provides classes and utilities for device notification, message handling, and
thread-safe device operations in a Textual-based terminal UI. Integrates with
Windows APIs to monitor device changes and manage device state.

Classes:
    USBIPDAttach: Device information message for `usbipd attach` command.
    USBIPDBind: Device information message for `usbipd attach` command.
    USBIPDDetach: Device information message for `usbipd detach` command.
    USBIPDUnbind: Device information message for `usbipd unbind` command.
    WMDeviceChange: `WM_DEVICECHANGE` message for refreshing connected device
        state.
    DeviceNotifier: Notifies TUI of Windows device changes.
    TUI: Main TUI application.

Functions:
    acquire_usbipd_lock: Handles a thread Lock, indicating if successfully
        acquired.
    with_device_lock: Decorates a TUI method for device lock acquisition.
    main: Main application entry.
"""

import argparse
import asyncio
import ctypes
import logging
import threading
import sys
from asyncio.windows_events import NULL
from collections import defaultdict
from contextlib import contextmanager
from ctypes import wintypes
from functools import lru_cache
from typing import (
    TYPE_CHECKING,
    Callable,
    ClassVar,
    Generator,
    TypeAlias,
    TypeVar,
    Union,
)

import win32api
import win32con
import win32gui

from picolynx.commands import *
from picolynx.components import (
    ConnectedTable,
    PersistedTable,
    TUIFooter,
    TUIHeader,
    TUINavigation,
)
from picolynx.exceptions import USBIPDError, WSLError
from picolynx.messages import *
from picolynx.utility import LOG_FMT, is_administrator
from picolynx.structures import *
from picolynx.themes import GALAXY_THEME

from rich.text import Text
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import BindingType
from textual.containers import Container
from textual.widgets.data_table import RowKey
from textual.logging import TextualHandler
from textual._path import CSSPathType

if TYPE_CHECKING:
    from _typeshed import ReadableBuffer
    from _win32typing import PyEventLogRecord  # pyright: ignore[reportMissingModuleSource]

LockCache: TypeAlias = defaultdict[str, threading.Lock]

LRESULT: TypeAlias = ctypes.c_ssize_t
UMSG: TypeAlias = ctypes.c_uint
WPARAM: TypeAlias = ctypes.c_size_t
LPARAM: TypeAlias = ctypes.c_ssize_t

WNDPROCTYPE = ctypes.WINFUNCTYPE(LRESULT, wintypes.HWND, UMSG, WPARAM, LPARAM)

USBIPDMessage: TypeAlias = Union[
    USBIPDAttach, USBIPDBind, USBIPDDetach, USBIPDUnbind
]

# type variable M is bound to USBIPDMessage
M = TypeVar("M", bound=USBIPDMessage)


class DeviceNotifier:
    """Notifies TUI of Windows device changes.

    Creates a hidden window, a window procedure, a dedicated thread for the
    message pump, and manages thread-safe communication back to the asyncio
    event loop for the TUI app.
    """

    def __init__(
        self,
        loop: asyncio.AbstractEventLoop,
        callback: Callable[[DBCEvent, str], None],
        log_level: int = logging.WARNING,
    ) -> None:
        """Initialises the `DeviceNotifier` class.

        Args:
            loop: The running event loop.

            callback: A callback, which will receive the `WM_DEVICECHANGE`
                event message `wparam` & `dbcp_name` of the port device.
        """
        if not callable(callback):
            raise ValueError("Callback attribute is not callable")
        self._loop = loop
        self._callback = callback
        self._thread = threading.Thread(target=self.message_pump, daemon=True)
        self._hwnd_ready = threading.Event()
        self._hwnd = None
        self._log = logging.getLogger(self.__class__.__name__)
        self._log.setLevel(log_level)

    @property
    def callback(self) -> Callable[[DBCEvent, str], None]:
        """Message callback function property."""
        return self._callback

    @property
    def hwnd(self) -> int | None:
        """Window handle property."""
        return self._hwnd

    @hwnd.deleter
    def hwnd(self) -> None:
        """Deletes the window handle property."""
        del self._hwnd

    @hwnd.setter
    def hwnd(self, window_handle: int | None) -> None:
        """Sets the window handle property."""
        if window_handle == NULL or window_handle is None:
            raise RuntimeError("Window handle (`hwnd`) is NULL")
        self._hwnd = window_handle
        self._hwnd_ready.set()

    @property
    def log(self) -> logging.Logger:
        """Logger property."""
        return self._log

    @property
    def loop(self) -> asyncio.AbstractEventLoop:
        """Running event loop property."""
        return self._loop

    @property
    def thread(self) -> threading.Thread:
        """Message pump thread property."""
        return self._thread

    def call_soon_threadsafe(
        self, callback: Callable[[DBCEvent, str], None], *args
    ) -> None:
        """Schedules a function call on the running event loop.

        Args:
            callback: A callback, which will receive the `wparam` & `lparam`
                from the `WM_DEVICECHANGE` event message.
        """
        self.loop.call_soon_threadsafe(callback, *args)

    def start(self) -> None:
        """Starts the `message_pump` thread.

        Raises:
            RuntimeError: On failure to start `win32gui.PumpMessages`.
        """
        self.thread.start()
        if not self._hwnd_ready.wait(timeout=5):
            raise RuntimeError("Failed to start `win32gui.PumpMessages`")

    def stop(self) -> None:
        """Stops the `message_pump` thread & initiates cleanup actions.

        `WM_CLOSE` -> `win32gui.DestroyWindow()` -> `WM_DESTROY` ->
        `win32gui.PostQuitMessage(0)` -> `WM_QUIT`.
        """
        if self.hwnd is not None:
            win32gui.PostMessage(self.hwnd, win32con.WM_CLOSE, NULL, NULL)
            self.thread.join(timeout=5)
            if self.thread.is_alive():
                self.log.warning("Thread did not exit correctly")
        else:
            self.log.warning("Window handle not set, cannot post `WM_CLOSE`")

    def create_window(self) -> None:
        """Creates a Windows message-only window."""
        wc = win32gui.WNDCLASS()
        hinst = win32api.GetModuleHandle(None)
        setattr(wc, "hInstance", hinst)
        setattr(wc, "lpszClassName", self.__class__.__name__)
        setattr(wc, "lpfnWndProc", WNDPROCTYPE(self.window_proc))
        class_atom = win32gui.RegisterClass(wc)
        # create a new window
        self.hwnd = win32gui.CreateWindow(
            class_atom,  # class name
            "Device Change Demo",  # window title
            NULL,  # style
            NULL,  # x
            NULL,  # y
            win32con.CW_USEDEFAULT,  # width
            win32con.CW_USEDEFAULT,  # height
            NULL,  # parent
            NULL,  # menu
            hinst,  # hinstance
            None,  # reserved
        )

    def message_pump(self) -> None:
        """Runs a message loop for Windows messages."""
        try:
            self.create_window()
            # infinite, blocking loop
            win32gui.PumpMessages()
        except Exception as e:
            self.log.exception(e)
            self._hwnd_ready.set()
        finally:
            self._hwnd = None

    def window_proc(
        self, hwnd: int, umsg: int, wparam: int, lparam: int
    ) -> int:
        """Processes Windows messages from the `message_pump`.

        Args:
            hwnd: A handle to the window class.

            umsg: The Windows message code.

            wparam: Additional message-specific data.

            lparam: A pointer to a message-specific structure.
        """
        self.log.debug(f"{umsg=:04X}, {wparam=:04X}, {lparam=:04X}")
        try:
            match umsg:
                case win32con.WM_DEVICECHANGE if lparam:
                    self.process_device_change(umsg, wparam, lparam)
                case win32con.WM_CLOSE | win32con.WM_DESTROY:
                    self.process_cleanup(hwnd, umsg, wparam, lparam)
                case _:
                    pass
        except Exception as e:
            self.log.exception(e)
        finally:
            return win32gui.DefWindowProc(hwnd, umsg, wparam, lparam)

    def process_cleanup(
        self, hwnd: int, umsg: int, wparam: int, lparam: int
    ) -> None:
        """Runs cleanup actions on `WM_CLOSE` & `WM_DESTROY` messages.

        Args:
            hwnd: A handle to the window class.

            umsg: The Windows message code.

            wparam: Additional message-specific data.

            lparam: A pointer to a message-specific structure.
        """
        self.log.debug(f"{umsg=:04X}, {wparam=:04X}, {lparam=:04X}")
        match umsg:
            case win32con.WM_CLOSE:
                self.log.info("Closing `win32gui.WNDCLASS`")
                win32gui.DestroyWindow(hwnd)
            case win32con.WM_DESTROY:
                self.log.info("Destroying `win32gui.WNDCLASS`")
                del self.hwnd
                win32gui.PostQuitMessage(0)
            case _:
                self.log.debug("Unexpected `umsg`")

    def process_device_change(
        self, umsg: int, wparam: int, lparam: int
    ) -> None:
        """Processes `WM_DEVICECHANGE` messages.

        Callbacks are scheduled on the main asyncio loop via the
        `call_soon_threadsafe` method of the running event loop.

        Args:
            wparam: Additional message-specific data.

            lparam: A pointer to a message-specific structure.

        Returns:
            Message processing result, which depends on the message.
        """
        try:
            if umsg != win32con.WM_DEVICECHANGE:
                self.log.error(f"Expected `WM_DEVICECHANGE` ({umsg=:04X})")
                return

            self.log.info(f"`WM_DEVICECHANGE` - `{DBCEvent(wparam).name}`")

            def post_devtype_message(wparam: DBCEvent, lparam: int) -> None:
                """Calls TUI callback if device type is `DBT_DEVTYP_PORT`."""

                dbcp_name = self._get_devtype_friendly_name(lparam)
                if dbcp_name is None:
                    return
                self.call_soon_threadsafe(self.callback, wparam, dbcp_name)

            match wparam:
                case DBCEvent.DBT_DEVNODES_CHANGED if lparam:
                    pass
                case DBCEvent.DBT_DEVICEARRIVAL if lparam:
                    post_devtype_message(wparam, lparam)
                case DBCEvent.DBT_DEVICEREMOVECOMPLETE:
                    post_devtype_message(wparam, lparam)
                case _:
                    self.log.warning("Unhandled device-change event")
        except Exception as e:
            self.log.exception(e)

    def _get_devtype_friendly_name(self, lparam: int) -> str | None:
        """Get a friendly name for a serial/port or volume device.

        When a device is attached to WSL and is disconnected, this triggers a
        `DBT_DEVICEREMOVECOMPLETE` event with a NULL `lparam`. Outside a WSL
        context, we see a valid `lparam` address we can test.

        Args:
            lparam: A pointer to the interface structure.

        Returns:
            A friendly name for PORT devices or `None` for other device types.
        """
        if lparam == NULL:
            return "UNK"

        hdr = DEV_BROADCAST_HDR.from_address(lparam)
        try:
            self.log.debug(f"`{DBCDeviceType(hdr.dbch_devicetype).name}`")
        except ValueError:
            self.log.error("Unknown device type")
            return None
        match hdr.dbch_devicetype:
            case DBCDeviceType.DBT_DEVTYP_PORT:
                serial = DEV_BROADCAST_PORT_W.from_address(lparam)
                offset = DEV_BROADCAST_PORT_W.dbcp_name.offset
                return ctypes.wstring_at(ctypes.addressof(serial) + offset)
            case DBCDeviceType.DBT_DEVTYP_VOLUME:
                volume = DEV_BROADCAST_VOLUME.from_address(lparam)
                if mask := volume.dbcv_unitmask:
                    drives = []
                    drives.extend(
                        chr(ord("A") + i) for i in range(26) if (mask >> i) & 1
                    )
                    return "|".join(f"{drive}:\\" for drive in drives)
        # not a PORT/SERIAL device
        return None


@contextmanager
def acquire_usbipd_lock(lock: threading.Lock) -> Generator[bool, None, None]:
    """Handles a thread Lock, indicating if successfully acquired.

    Args:
        lock: A `Lock` object.

    Yields:
        `True` if the `Lock` was acquired else `False`.
    """
    acquired = lock.acquire(blocking=False)
    try:
        yield acquired
    finally:
        if acquired:
            lock.release()


def with_device_lock(
    func: Callable[["TUI", M], None],
) -> Callable[["TUI", M], None]:
    """Decorates a TUI method for device lock acquisition.

    Args:
        func: The method to wrap, which takes a `USBIPDMessage` subclass.

    Returns:
        A wrapped method.
    """

    def wrapper(self: "TUI", msg: M) -> None:
        """Wraps the passed TUI method.

        Returns early if the `msg.device` has a `busid` that is None or if
        the device `Lock` is not acquired.

        Args:
            self: `TUI` object.
            msg: A message containing device information.

        Returns:
            A wrapped TUI method.
        """
        device = msg.device
        if not device.busid:
            return
        with self.thread_lock:
            usbipd_lock = self.usbipd_lock_map[device.busid]

        with acquire_usbipd_lock(usbipd_lock) as acquired:
            if not acquired:
                return
            return func(self, msg)

    return wrapper


class TUI(App):
    """Main TUI application."""

    BINDINGS: ClassVar[list[BindingType]] = [
        ("a", "manual_attach", "attach"),
        ("b", "manual_bind", "bind"),
        ("d", "manual_detach", "detach"),
        ("u", "manual_unbind", "unbind"),
        ("r", "manual_refresh", "refresh"),
    ]

    CSS_PATH: ClassVar[CSSPathType | None] = "app.tcss"

    def __init__(self, log_level: int = logging.WARNING) -> None:
        """Initialises TUI App.

        Args:
           log_level: Sets the TUI logging level. Defaults to
            `logging.INFO` (20).
        """
        super().__init__()
        self._connection_cache: dict[str, USBIPDDevice] = {}
        self._usbipd_lock_map: LockCache = defaultdict(threading.Lock)
        self._thread_lock: threading.Lock = threading.Lock()
        self._thread_exit: threading.Event = threading.Event()
        self.__log = logging.getLogger(self.__class__.__name__)
        self.__log.setLevel(log_level)

    @property
    def usbipd_lock_map(self) -> LockCache:
        """Property for device threading lock."""
        return self._usbipd_lock_map

    @property
    def thread_exit(self) -> threading.Event:
        """Property for threading exit `Event`."""
        return self._thread_exit

    @property
    @lru_cache(1)
    def table_connected(self) -> ConnectedTable:
        """Property for connected devices `DataTable` widget."""
        return self.query_one("#table-connected", ConnectedTable)

    @property
    @lru_cache(1)
    def table_persisted(self) -> PersistedTable:
        """Property for Windows events `DataTable` widget."""
        return self.query_one("#table-persisted", PersistedTable)

    @property
    def thread_lock(self) -> threading.Lock:
        """Property for threading lock."""
        return self._thread_lock

    def device_from_selected(
        self, row_key: RowKey | None
    ) -> USBIPDDevice | None:
        """Retrieves device from the cache using selected row busid key.

        Args:
            row_key: The `RowKey` for the selected `DataTable` row, which is
                set to the Bus ID of a device.

        Returns:
            A `USBIPDDevice`, if busid was in the cache or `None`.
        """
        if row_key and isinstance(row_key.value, str):
            return self._connection_cache.get(row_key.value)
        return None

    def action_manual_attach(self) -> None:
        """Triggers device attachment on `manual_attach` action."""
        selected_row_key = self.table_connected.row_selected_key
        if device := self.device_from_selected(selected_row_key):
            self.__log.info(f"Manual attach @ BUSID {device.busid}")
            self.post_message(USBIPDAttach(device))

    def action_manual_bind(self) -> None:
        """Triggers device binding on `manual_bind` action."""
        selected_row_key = self.table_connected.row_selected_key
        if device := self.device_from_selected(selected_row_key):
            self.__log.info(f"Manual bind @ BUSID {device.busid}")
            self.post_message(USBIPDBind(device))

    def action_manual_detach(self) -> None:
        """Triggers device detach on `manual_detach` action."""
        selected_row_key = self.table_connected.row_selected_key
        if device := self.device_from_selected(selected_row_key):
            self.__log.info(f"Manual detach @ BUSID {device.busid}")
            self.post_message(USBIPDDetach(device))

    def action_manual_unbind(self) -> None:
        """Triggers device unbind on `manual_unbind` action."""
        selected_row_key = self.table_connected.row_selected_key
        if device := self.device_from_selected(selected_row_key):
            self.__log.info(f"Manual unbind @ BUSID {device.busid}")
            self.post_message(USBIPDUnbind(device))

    def action_manual_refresh(self) -> None:
        """Triggers a connected device refresh on `manual_refresh` action."""
        self.incremental_device_update()

    def compose(self) -> ComposeResult:
        """Composes the TUI widgets."""
        yield TUIHeader(id="header")
        with Container(id="container-main"):
            yield TUINavigation()
        yield TUIFooter(id="footer")

    def get_connected_row(self, device: USBIPDDevice) -> list[Text]:
        """Parses a row from a USBIPDDevice object.

        Args:
            device: A device to parse into a table row.
        """
        md = ""
        return [
            Text(device.description, style=md, overflow="ellipsis"),
            Text(f"{device.busid}", style=md, justify="center"),
            Text(f"{device.vid}:{device.pid}", style=md, justify="center"),
            Text(f"{device.isbound}", style=md, justify="center"),
            Text(f"{device.isattached}", style=md, justify="center"),
        ]

    def get_persisted_row(self, device: USBIPDDevice) -> list[Text]:
        """Parses a row from a USBIPDDevice object.

        Args:
            device: A device to parse into a table row.
        """
        md = ""
        return [
            Text(device.description, style=md, overflow="ellipsis"),
            Text(f"{device.persistedguid}", style=md, justify="center"),
        ]

    @on(USBIPDAttach)
    def handle_attach_device(self, msg: USBIPDAttach) -> None:
        """Forwards `USBIPDAttachDevice` messages to a dedicated worker.

        Args:
            msg: A `USBIPDAttachDevice` message.
        """
        if not msg.device.isattached:
            # handles unbound before attach logic
            self.worker_attach_device(msg)

    @on(USBIPDBind)
    def handle_bind_device(self, msg: USBIPDBind) -> None:
        """Forwards `USBIPDAttachDevice` messages to a dedicated worker.

        Args:
            msg: A `USBIPDAttachDevice` message.
        """
        if not msg.device.isattached:
            self.worker_bind_device(msg)

    @on(USBIPDDetach)
    def handle_detach_device(self, msg: USBIPDDetach) -> None:
        """Forwards messages to a dedicated worker.

        Args:
            msg: A `USBIPDDetachDevice` message.
        """
        self.worker_detach_device(msg)

    @on(USBIPDUnbind)
    def handle_unbind_device(self, msg: USBIPDUnbind) -> None:
        """Forwards messages to a dedicated worker.

        Args:
            msg: A `USBIPDUnbindDevice` message.
        """
        self.worker_unbind_device(msg)

    @on(WMDeviceChange)
    def handle_device_change(self) -> None:
        """Handles Windows device change messages."""
        self.incremental_device_update()

    def handle_wm_events(self, wparam: DBCEvent, name: str) -> None:
        """Handles Windows messages from `DeviceNotifier`.

        Args:
            device_event: A device broadcast message code.
        """
        event_name = DBCEvent(wparam).name
        self.__log.info(f"`{event_name}` ({wparam:04X}) - {name}")
        try:
            self.post_message(WMDeviceChange(wparam, name))
        except LookupError as e:
            self.__log.exception(e)

    def incremental_device_update(self) -> None:
        """Updates `DataTable` widgets to reflect device changes."""

        connections = {d.busid: d for d in run_usbipd_state() if d.busid}

        current = set(connections.keys())
        previous = set(self._connection_cache.keys())

        updated = {
            busid
            for busid in previous & current
            if self._check_cache(connections[busid])
        }

        self._update_removed_devices(previous - current)
        self._update_added_devices(current - previous, connections)
        self._update_modified_devices(updated, connections)
        self._connection_cache = connections

    def initial_populate_devices(self) -> None:
        """Populates the device `DataTable`."""
        self.table_connected.clear()

        new_cache = {d.busid: d for d in run_usbipd_state() if d.busid}
        for busid, device in new_cache.items():
            con_row = self.get_connected_row(device)
            self.table_connected.add_row(*con_row, key=busid)
            if device.isbound:
                per_row = self.get_persisted_row(device)
                self.table_persisted.add_row(*per_row, key=busid)
        self._connection_cache = new_cache

    def on_mount(self) -> None:
        """Handles TUI `mount` event."""
        self.register_theme(GALAXY_THEME)
        self.app.theme = "galaxy"

        self.initial_populate_devices()

        running_loop = asyncio.get_running_loop()
        self._notifier = DeviceNotifier(
            running_loop, self.handle_wm_events, self.__log.level
        )
        self._notifier.start()

    def on_unmount(self) -> None:
        """Handles TUI `unmount` event."""
        self._notifier.stop()
        self.thread_exit.set()

    @work(thread=True)
    @with_device_lock
    def worker_attach_device(self, msg: USBIPDAttach) -> None:
        """Handles blocking `run_usbipd_bind` & `run_usbipd_attach` calls.

        Args:
            msg: Device information message for `usbipd` commands.
        """
        if (device := msg.device).busid is None:
            return

        try:
            active_distro = run_wsl_list()
        except WSLError as e:
            self.__log.error(e, exc_info=True)
            self.notify(str(e), title="WSL Error", severity="error")
            return

        # mitigate `usbipd_bind` & `usbipd_attach` race conditions
        try:
            if not active_distro:
                self.notify("No active WSL distro", severity="warning")
                return

            for connected_device in run_usbipd_state():
                match connected_device:
                    # `busid` matches, but device is not bound or attached
                    case USBIPDDevice(busid=device.busid, isbound=False):
                        run_usbipd_bind(device.busid)
                        run_usbipd_attach(device.busid)
                        break
                    # `busid` matches, but device is bound & not attached
                    case USBIPDDevice(busid=device.busid, isattached=False):
                        run_usbipd_attach(device.busid)
                        break
                    case _:
                        continue
        except USBIPDError as e:
            self.__log.warning(e)
            self.notify(str(e), title="USBIPD Error", severity="warning")
        else:
            self.notify(device.description, title="Attached", timeout=1)
        finally:
            self.incremental_device_update()

    @work(thread=True)
    @with_device_lock
    def worker_bind_device(self, msg: USBIPDBind) -> None:
        """Handles blocking `run_usbipd_bind` calls.

        Args:
            msg: Device information message for `usbipd` commands.
        """
        if (device := msg.device).busid is None:
            return

        # mitigate `usbipd_bind` & `usbipd_attach` race conditions
        try:
            for connected_device in run_usbipd_state():
                match connected_device:
                    # `busid` matches, but device is not bound or attached
                    case USBIPDDevice(busid=device.busid, isbound=False):
                        run_usbipd_bind(device.busid)
                        break
                    # `busid` matches, but device is bound & not attached
                    case USBIPDDevice(busid=device.busid, isattached=False):
                        self.__log.info("Device is already bound")
                        break
                    case _:
                        continue
        except USBIPDError as e:
            self.__log.exception(e)
            self.notify(str(e), title="USBIPD Error", severity="warning")
        else:
            self.notify(device.description, title="Bound", timeout=1)
        finally:
            self.incremental_device_update()

    @work(thread=True)
    @with_device_lock
    def worker_detach_device(self, msg: USBIPDDetach) -> None:
        """Handles blocking `run_usbipd_detach` calls.

        Args:
            msg: Device information message for `usbipd` commands.
        """
        if (device := msg.device).busid is None:
            return

        # mitigate `usbipd_bind` & `usbipd_attach` race conditions
        try:
            for connected_device in run_usbipd_state():
                match connected_device:
                    # `busid` matches & device is attached
                    case USBIPDDevice(busid=device.busid, isattached=True):
                        run_usbipd_detach(device.busid)
                        break
                    # `busid` matches, but device is not attached
                    case USBIPDDevice(busid=device.busid, isattached=False):
                        self.__log.warning("Device not found")
                        break
                    case _:
                        continue
        except USBIPDError as e:
            self.__log.exception(e)
            self.notify(str(e), title="USBIPD Error", severity="warning")
        else:
            self.notify(device.description, title="Detached", timeout=1)
        finally:
            # ensure UI is synced after detach
            self.incremental_device_update()

    @work(thread=True)
    @with_device_lock
    def worker_unbind_device(self, msg: USBIPDUnbind) -> None:
        """Handles blocking `run_usbipd_unbind` calls"""

        if (device := msg.device).busid is None:
            return

        # mitigate `usbipd_bind` & `usbipd_attach` race conditions
        try:
            for connected_device in run_usbipd_state():
                match connected_device:
                    # `busid` matches, & device is bound & possibly attached
                    case USBIPDDevice(busid=device.busid, isbound=True):
                        run_usbipd_unbind(device.busid)
                        break
                    # `busid` matches, but device is not bound
                    case USBIPDDevice(busid=device.busid, isbound=False):
                        self.__log.warning("Device not found")
                        break
                    case _:
                        continue
        except USBIPDError as e:
            self.__log.exception(e)
            self.notify(str(e), title="USBIPD Error", severity="warning")
        else:
            self.notify(device.description, title="Unbound")
        finally:
            self.incremental_device_update()

    def _check_cache(self, device: USBIPDDevice) -> bool:
        """Checks a device against the connection cache."""
        return self._connection_cache.get(str(device.busid)) != device

    def _update_removed_devices(self, busids: set[str]) -> None:
        """Removes rows in connected & persisted `DataTable` widgets.

        Args:
            busids: BUS ID values for removed devices.
        """
        # removed devices
        for busid in busids:
            try:
                row_key = RowKey(busid)
                if row_key in self.table_connected.rows:
                    self.table_connected.remove_row(row_key)
                if row_key in self.table_persisted.rows:
                    self.table_persisted.remove_row(row_key)
            except KeyError as e:
                self.__log.warning(f"Missing row @ `{busid}`", exc_info=True)

    def _update_added_devices(
        self, busids: set[str], connections: dict[str, USBIPDDevice]
    ) -> None:
        """Adds rows in `DataTable` widgets for new device connections.

        Args:
            busids: BUS ID values for new devices.
        """
        # added devices
        for busid in busids:
            new_device = connections[busid]
            con_row = self.get_connected_row(new_device)
            self.table_connected.add_row(*con_row, key=busid)
            index = self.table_connected.get_row_index(RowKey(busid))
            self.table_connected.move_cursor(row=index)
            self.table_connected.action_select_cursor()

            if new_device.isbound:
                per_row = self.get_persisted_row(new_device)
                self.table_persisted.add_row(*per_row, key=busid)

    def _update_modified_devices(
        self, busids: set[str], connections: dict[str, USBIPDDevice]
    ) -> None:
        """Updates rows in `DataTable` widgets for modified devices.

        Args:
            busids: BUS ID values for modified devices.
        """
        # updated devices
        for busid in busids:
            updated_device = connections[busid]

            # update connected devices `DataTable` row
            con_row = self.get_connected_row(updated_device)
            for key, value in enumerate(con_row, start=0):
                self.table_connected.update_cell(busid, str(key), value)

            row_in_persisted = self.table_persisted.rows.get(RowKey(busid))
            if updated_device.isbound:
                if not row_in_persisted:
                    per_row = self.get_persisted_row(updated_device)
                    self.table_persisted.add_row(*per_row, key=busid)
            elif row_in_persisted:
                self.table_persisted.remove_row(busid)


def parse_args() -> argparse.Namespace:
    """Parses logging level arguments."""
    parser = argparse.ArgumentParser("Run `PicoLynx` TUI")
    parser.add_argument(
        "--log-level",
        default="WARNING",
        choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
        help="Set logging level (default: `WARNING`)",
    )
    return parser.parse_args()


def main() -> None:
    """Main application entry."""

    arguments = parse_args()
    LOG_LEVEL = getattr(logging, arguments.log_level.upper(), logging.WARNING)

    logging.basicConfig(
        level=LOG_LEVEL, format=LOG_FMT, handlers=(TextualHandler(),)
    )

    if not is_administrator():
        # a nonzero value is considered 'abnormal' termination
        sys.exit(0) if run_as_administrator() else sys.exit(1)
    try:
        app = TUI(log_level=LOG_LEVEL)
        app.run()
    except KeyboardInterrupt as e:
        logging.info("`KeyboardInterrupt` received")
    finally:
        pass


if __name__ == "__main__":
    main()
