"""sshPilot plugin API.

This module is the ONLY supported import surface for plugins:

    from sshpilot.plugins.api import (
        SshPilotPlugin, PluginContext, ProtocolBackend,
        SpawnSpec, FieldSpec, Capability, API_VERSION,
    )

Everything else under ``sshpilot.*`` is private and may change without
notice. Plugins declare the API major version they target in their
``plugin.json``; the loader refuses to activate plugins whose major
version does not match ``API_VERSION[0]``.
"""

from __future__ import annotations

import abc
import enum
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple

# (major, minor). Bump minor for additive changes, major for breaking ones.
# 1.1: PluginContext.delete_secret; get_secret/set_secret wired to the keyring.
# 1.2: Event system (ctx.events), UI extension (ctx.ui), terminal control
#      (ctx.open_connection), key generation (ctx.generate_key), per-plugin
#      scoped ctx.secrets/ctx.settings, ctx.run_on_ui_thread, ctx.plugin_id.
# 1.3: Connection groups — ctx.create_group / add_connection_to_group /
#      add_connection_group (for multi-node provisioning); ctx.update_connection
#      (refresh an existing connection in place).
# 1.4: ctx.list_connections() — read-only snapshot of all saved connections.
# 1.5: power APIs — ctx.run_command (one-shot remote command via the native
#      SSH/auth path), ctx.get_effective_ssh_config, ctx.copy_key_to_host,
#      ctx.list_keys/ctx.delete_key, terminal/session access
#      (ctx.list_sessions/ctx.read_terminal/ctx.send_terminal), and
#      self-contained ctx.data_dir/ctx.files/ctx.http helpers.
# 1.6: ctx.open_command_terminal(nickname, remote_command, title=) — open a new
#      terminal tab running a one-off command on a connection's host, over the
#      single native SSH/auth path (no new transport). For streamed output such
#      as `docker logs -f`, interactive `docker exec -it`, or `top`.
# 1.7: ctx.ui.register_connection_action(action_id, label, icon_name, callback)
#      — contribute an item to the connection-list right-click menu; callback
#      receives the connection nickname.
# 1.8: ctx.ui.register_page(..., add_menu_item=False, on_activate=cb) — register
#      a page with no Tools-menu entry (opened directly, e.g. one tab per host),
#      and/or have the menu entry run a callback instead of opening the page.
# 1.9: ctx.acquire_multiplex(nickname) / ctx.release_multiplex(nickname) — keep a
#      shared SSH ControlMaster warm for a host while a surface is open; run_command
#      then transparently reuses that one connection (no re-auth per call). For
#      polling surfaces such as the Docker Console.
API_VERSION: Tuple[int, int] = (1, 9)

# Stable event names and event payload types live in host.py; re-exported here
# so plugins import everything from sshpilot.plugins.api. (host.py imports
# nothing from this module, so there is no import cycle.)
from .host import ConnectionInfo, Events, SessionInfo  # noqa: E402,F401


class Capability(enum.Enum):
    """Things a protocol backend can do. UI code uses these to show/hide
    actions (SFTP button, port-forwarding page, ssh-copy-id, ...) instead
    of checking ``protocol == 'ssh'``."""

    FILE_TRANSFER = "file-transfer"          # SFTP/SCP-style browsing
    PORT_FORWARDING = "port-forwarding"
    REMOTE_COMMAND = "remote-command"
    AUTH_PASSWORD = "auth-password"
    AUTH_KEY = "auth-key"
    KEY_DEPLOYMENT = "key-deployment"        # ssh-copy-id flow
    AGENT = "ssh-agent"
    JUMP_HOST = "jump-host"


@dataclass
class SpawnSpec:
    """A fully prepared, ready-to-spawn command for a terminal tab.

    This is the generalized successor of ``SSHConnectionCommand``: argv +
    env is all the terminal layer fundamentally needs. ``extras`` carries
    protocol-private hints during the migration period (e.g. the SSH
    backend's sshpass/askpass flags, which terminal.py still orchestrates);
    new protocols should NOT rely on extras being interpreted by core.
    """

    argv: List[str]
    env: Dict[str, str] = field(default_factory=dict)
    working_directory: Optional[str] = None  # spawn cwd; defaults to ~ if None
    # Transitional, protocol-private data. See docstring.
    extras: Dict[str, Any] = field(default_factory=dict)


@dataclass
class FieldSpec:
    """Declarative description of one connection-editor field.

    Phase 2: the connection dialog renders these for non-SSH protocols
    instead of hardcoding rows. ``key`` is where the value lives in
    ``Connection.data``.
    """

    key: str
    label: str
    kind: str = "text"  # text | int | password | file | choice | switch
    default: Any = None
    choices: Optional[List[Tuple[str, str]]] = None  # (value, label)
    placeholder: str = ""
    required: bool = False
    group: str = "general"  # dialog section the row belongs to


class ProtocolBackend(abc.ABC):
    """A connection protocol (ssh, telnet, serial, mosh, ...).

    One instance per protocol is registered at plugin activation and
    shared across all connections; backends must therefore be stateless
    with respect to individual connections (state belongs to the spawned
    process / the SpawnSpec callbacks).
    """

    #: Stable identifier stored in Connection.data["protocol"].
    protocol_id: str = ""
    #: Human-readable name for the connection dialog's protocol selector.
    display_name: str = ""
    default_port: Optional[int] = None

    @abc.abstractmethod
    def capabilities(self) -> frozenset:
        """Return a frozenset of Capability members."""

    @abc.abstractmethod
    def build_spawn(self, connection: Any, ctx: "PluginContext") -> SpawnSpec:
        """Turn a Connection into something the terminal can spawn.

        Must not block on network I/O: anything slow belongs in the child
        process itself. May raise ``ProtocolError`` with a user-presentable
        message.

        Must be **stateless per connection**: derive everything from
        ``connection.data`` and the environment. ``ctx`` here is a host-less
        spawn context (see ``PluginContext.for_spawn``) — ``ctx.secrets`` and
        ``ctx.settings`` are available and correctly scoped, but ``ctx.ui`` and
        ``ctx.events`` are ``None`` and must not be used.
        """

    def connection_fields(self) -> List[FieldSpec]:
        """Fields the connection editor should show for this protocol.

        Default: empty (protocol uses only the shared host/nickname
        fields). The built-in SSH backend also returns [] because the
        existing hand-built dialog remains authoritative for SSH.
        """
        return []

    def validate(self, data: Dict[str, Any]) -> List[str]:
        """Return a list of human-readable validation errors (empty = ok)."""
        return []


class ProtocolError(RuntimeError):
    """Raised by build_spawn(); message is shown to the user."""


class _EventsFacade:
    """Per-plugin view of the event bus (``ctx.events``). Subscriptions are
    tagged with the plugin id so they can be cleaned up on deactivate."""

    # Event-name constants, mirrored for convenience (ctx.events.APP_STARTED).
    APP_STARTED = Events.APP_STARTED
    APP_SHUTDOWN = Events.APP_SHUTDOWN
    CONNECTION_CREATED = Events.CONNECTION_CREATED
    CONNECTION_UPDATED = Events.CONNECTION_UPDATED
    CONNECTION_DELETED = Events.CONNECTION_DELETED
    SESSION_OPENED = Events.SESSION_OPENED
    SESSION_CLOSED = Events.SESSION_CLOSED

    def __init__(self, bus: Any, plugin_id: str):
        self._bus = bus
        self._plugin_id = plugin_id

    def subscribe(self, event: str, callback: Callable[[Any], None]) -> None:
        self._bus.subscribe(event, callback, plugin_id=self._plugin_id)

    def unsubscribe(self, event: str, callback: Callable[[Any], None]) -> None:
        self._bus.unsubscribe(event, callback, plugin_id=self._plugin_id)


class _UiFacade:
    """Per-plugin UI surface (``ctx.ui``). Page ids are namespaced by plugin
    so two plugins can both register a page called "deploy"."""

    def __init__(self, ui_host: Any, plugin_id: str):
        self._ui = ui_host
        self._plugin_id = plugin_id

    def _full(self, page_id: str) -> str:
        return f"{self._plugin_id}:{page_id}"

    def register_page(self, page_id: str, title: str, icon_name: str,
                      factory: Callable[[], Any], *,
                      add_menu_item: bool = True,
                      on_activate: Optional[Callable[[], None]] = None) -> None:
        """Register a page. ``factory`` is a zero-arg callable returning a
        ``Gtk.Widget`` built on demand when the page is first opened. Safe to
        call from ``activate``; the page appears under the Tools menu once the
        window is ready.

        ``add_menu_item=False`` (API >= 1.8) registers the page without a Tools-menu
        entry — for pages opened directly via ``open_page`` (e.g. one tab per
        host). ``on_activate`` (API >= 1.8), when given, is called when the
        Tools-menu item is chosen instead of opening this page — e.g. to open a
        different (per-host) page."""
        self._ui.register_page(self._full(page_id), title, icon_name, factory,
                               plugin_id=self._plugin_id,
                               add_menu_item=add_menu_item, on_activate=on_activate)

    def open_page(self, page_id: str) -> None:
        """Open (or focus) a registered page as a tab. Valid after
        ``app_started``; calls made earlier are queued."""
        self._ui.open_page(self._full(page_id))

    def notify(self, message: str, timeout: int = 3) -> None:
        """Show a transient in-app toast. Valid after ``app_started``;
        earlier calls are queued."""
        self._ui.notify(message, timeout)

    def register_connection_action(self, action_id: str, label: str,
                                   icon_name: str,
                                   callback: Callable[[str], None]) -> None:
        """Add an item to the connection-list right-click menu (API >= 1.7).

        ``callback`` is invoked with the right-clicked connection's nickname.
        Safe to call from ``activate``; the item appears for SSH connections
        once the window is ready. Use it to open a plugin page targeting that
        host (e.g. ``ctx.ui.open_page(...)`` then point the page at the host)."""
        register = getattr(self._ui, "register_connection_action", None)
        if callable(register):
            register(action_id, label, icon_name, callback, plugin_id=self._plugin_id)


class _SecretStore:
    """Per-plugin keyring access (``ctx.secrets``), auto-scoped by plugin id."""

    def __init__(self, connection_manager: Any, plugin_id: str):
        self._cm = connection_manager
        self._plugin_id = plugin_id

    @staticmethod
    def _check_key(key: str) -> None:
        if not key:
            raise ValueError("Secret key must be non-empty")

    def get(self, key: str) -> Optional[str]:
        self._check_key(key)
        return self._cm.get_plugin_secret(self._plugin_id, key)

    def set(self, key: str, value: str) -> None:
        self._check_key(key)
        if not self._cm.store_plugin_secret(self._plugin_id, key, value):
            raise RuntimeError("No secure storage backend available")

    def delete(self, key: str) -> bool:
        self._check_key(key)
        return bool(self._cm.delete_plugin_secret(self._plugin_id, key))


class _SettingStore:
    """Per-plugin config access (``ctx.settings``), namespaced under
    ``plugins.<plugin_id>.<key>`` in the app config. For non-secret data;
    use ``ctx.secrets`` for credentials."""

    def __init__(self, app_config: Any, plugin_id: str):
        self._config = app_config
        self._plugin_id = plugin_id

    def _full(self, key: str) -> str:
        return f"plugins.{self._plugin_id}.{key}"

    def get(self, key: str, default: Any = None) -> Any:
        return self._config.get_setting(self._full(key), default)

    def set(self, key: str, value: Any) -> None:
        self._config.set_setting(self._full(key), value)


@dataclass
class CommandResult:
    """Outcome of ``ctx.run_command``: the remote command's exit code and its
    captured output. ``exit_code`` is -1 when the command could not be run at
    all (unknown connection, ssh missing, timeout)."""
    exit_code: int
    stdout: str = ""
    stderr: str = ""

    @property
    def ok(self) -> bool:
        return self.exit_code == 0


@dataclass
class HttpResponse:
    """Result of an ``ctx.http`` call."""
    status: int
    text: str = ""
    headers: Dict[str, str] = field(default_factory=dict)

    def json(self) -> Any:
        import json as _json
        return _json.loads(self.text)

    @property
    def ok(self) -> bool:
        return 200 <= self.status < 300


class _FilesFacade:
    """Sandboxed file access (``ctx.files``) rooted at the plugin's own data
    directory (``ctx.data_dir``). All paths are resolved relative to that root
    and escapes (``..`` / absolute paths leaving the root) are rejected, so a
    plugin can persist caches/state without hand-rolling XDG paths."""

    def __init__(self, root_getter: Callable[[], str]):
        self._root_getter = root_getter

    def path(self, rel: str) -> str:
        """Absolute path for ``rel`` inside the plugin data dir (creating the
        dir). Raises ValueError if ``rel`` would escape the data dir."""
        import os
        root = self._root_getter()
        full = os.path.realpath(os.path.join(root, rel))
        if full != root and not full.startswith(root + os.sep):
            raise ValueError(f"Path {rel!r} escapes the plugin data directory")
        return full

    def exists(self, rel: str) -> bool:
        import os
        return os.path.exists(self.path(rel))

    def read_text(self, rel: str, encoding: str = "utf-8") -> str:
        with open(self.path(rel), "r", encoding=encoding) as fh:
            return fh.read()

    def read_bytes(self, rel: str) -> bytes:
        with open(self.path(rel), "rb") as fh:
            return fh.read()

    def write_text(self, rel: str, data: str, encoding: str = "utf-8") -> None:
        import os
        full = self.path(rel)
        os.makedirs(os.path.dirname(full), exist_ok=True)
        with open(full, "w", encoding=encoding) as fh:
            fh.write(data)

    def write_bytes(self, rel: str, data: bytes) -> None:
        import os
        full = self.path(rel)
        os.makedirs(os.path.dirname(full), exist_ok=True)
        with open(full, "wb") as fh:
            fh.write(data)


class _HttpFacade:
    """Minimal blocking HTTP client (``ctx.http``) over urllib so plugins
    don't hand-roll requests. Blocking — call from a worker thread and marshal
    results back with ``ctx.run_on_ui_thread``."""

    def get(self, url: str, *, headers: Optional[Dict[str, str]] = None,
            timeout: float = 30) -> HttpResponse:
        return self._request("GET", url, headers=headers, timeout=timeout)

    def post(self, url: str, *, data: Optional[bytes] = None,
             json: Any = None, headers: Optional[Dict[str, str]] = None,
             timeout: float = 30) -> HttpResponse:
        body = data
        hdrs = dict(headers or {})
        if json is not None:
            import json as _json
            body = _json.dumps(json).encode("utf-8")
            hdrs.setdefault("Content-Type", "application/json")
        return self._request("POST", url, data=body, headers=hdrs,
                             timeout=timeout)

    @staticmethod
    def _request(method: str, url: str, *, data: Optional[bytes] = None,
                 headers: Optional[Dict[str, str]] = None,
                 timeout: float = 30) -> HttpResponse:
        import urllib.request
        import urllib.error
        if not str(url).lower().startswith(("http://", "https://")):
            raise ValueError("ctx.http only supports http(s) URLs")
        req = urllib.request.Request(url, data=data, method=method,
                                     headers=dict(headers or {}))
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                raw = resp.read()
                status = getattr(resp, "status", None)
                if status is None and hasattr(resp, "getcode"):
                    status = resp.getcode()
                return HttpResponse(
                    status=int(status or 0),
                    text=raw.decode("utf-8", "replace"),
                    headers={k: v for k, v in resp.headers.items()})
        except urllib.error.HTTPError as exc:
            raw = exc.read() if hasattr(exc, "read") else b""
            return HttpResponse(status=exc.code,
                                text=raw.decode("utf-8", "replace"),
                                headers={k: v for k, v in (exc.headers or {}).items()})


class PluginContext:
    """Capabilities handed to a plugin on activation.

    Deliberately narrow: plugins get *named* abilities, never raw access
    to windows, the keyring, or private modules. Grow this by adding
    methods, not by exposing internals.

    One context is created per plugin, so ``plugin_id`` is known and the
    ``secrets``/``settings`` accessors are auto-scoped.

    Lifecycle: ``activate(ctx)`` is for registration only — register
    protocols/pages, subscribe to events, read settings. Live UI, terminal,
    and key-generation calls (``ui.open_page``/``ui.notify``,
    ``open_connection``, ``generate_key``) are valid only after the
    ``app_started`` event; calls made earlier are safely queued where possible.
    """

    def __init__(self, *, plugin_id: str, app_config: Any,
                 connection_manager: Any, protocol_registry: Any,
                 host: Any = None):
        self.plugin_id = plugin_id
        # Kept as live attributes: the built-in SSH backend reads them in
        # build_spawn(). Treat as an advanced escape hatch in plugins.
        self.config = app_config
        self.connection_manager = connection_manager
        self._protocols = protocol_registry
        self._host = host

        self.events = _EventsFacade(host.events, plugin_id) if host is not None else None
        self.ui = _UiFacade(host.ui, plugin_id) if host is not None else None
        self.secrets = _SecretStore(connection_manager, plugin_id)
        self.settings = _SettingStore(app_config, plugin_id)
        # Self-contained helpers (no host needed) — available even in for_spawn.
        self.files = _FilesFacade(self._data_dir)
        self.http = _HttpFacade()

    @classmethod
    def for_spawn(cls, *, plugin_id: str, app_config: Any,
                  connection_manager: Any, protocol_registry: Any) -> "PluginContext":
        """Build a host-less context for ProtocolBackend.build_spawn().

        Created by the terminal at spawn time, scoped to the plugin that
        registered the protocol. ``events``/``ui`` are None (build_spawn must
        not touch them); ``secrets``/``settings`` work and are correctly
        scoped, so a backend may read its own stored config when assembling
        argv/env."""
        return cls(plugin_id=plugin_id, app_config=app_config,
                   connection_manager=connection_manager,
                   protocol_registry=protocol_registry, host=None)

    # --- registration -------------------------------------------------
    def register_protocol(self, backend: ProtocolBackend) -> None:
        self._protocols.register(backend, plugin_id=self.plugin_id)

    # --- connections --------------------------------------------------
    def add_connection(self, data: Dict[str, Any]) -> "ConnectionInfo":
        """Create and persist a new connection (used by provider plugins,
        e.g. VPS provisioning). Returns a read-only ``ConnectionInfo``; raises
        ValueError if the data is invalid (including a duplicate nickname)."""
        conn = self.connection_manager.add_connection_from_data(data)
        return ConnectionInfo.from_connection(conn)

    def update_connection(self, nickname: str, data: Dict[str, Any]) -> bool:
        """Update an existing connection (by nickname) in place — rewrites its
        stored settings and re-stores its password. Returns False if no
        connection with that nickname exists. Use together with
        ``add_connection`` to refresh a provisioned host whose address or
        credentials changed (e.g. a workspace was stopped and restarted)."""
        conn = self.connection_manager.find_connection_by_nickname(nickname)
        if conn is None:
            return False
        return bool(self.connection_manager.update_connection(conn, dict(data)))

    def list_connections(self) -> List["ConnectionInfo"]:
        """Return a read-only snapshot of every saved connection as
        ``ConnectionInfo`` objects. Safe to call any time after load (it reads
        the persisted list, not live UI). Use it to drive a dashboard, an
        export, or a 'apply to existing' action; subscribe to the
        ``connection_*`` events to keep a derived view fresh."""
        conns = getattr(self.connection_manager, "connections", None) or []
        return [ConnectionInfo.from_connection(c) for c in conns]

    def open_connection(self, nickname: str) -> bool:
        """Open a terminal tab for an existing connection (by nickname).
        Returns False if unknown or the UI isn't ready. Valid after
        ``app_started``."""
        return self._host.open_connection(nickname) if self._host is not None else False

    def open_command_terminal(self, nickname: str, remote_command: str,
                              *, title: Optional[str] = None,
                              pty_prompt: Optional[str] = None,
                              pty_response: Optional[str] = None) -> bool:
        """Open a new terminal tab that runs ``remote_command`` on the host of
        the connection ``nickname`` (API >= 1.6).

        The command runs over the app's single native SSH path (the same
        ``~/.ssh/config`` / ProxyJump / stored-credential handling as a normal
        session) — no new transport is introduced. Use this for streamed or
        interactive output that ``run_command`` (one-shot, captured) cannot
        show, e.g. ``docker logs -f``, ``docker exec -it <c> sh``, or live
        ``docker stats``. ``title`` overrides the tab title.

        ``pty_prompt``/``pty_response`` arm a one-shot auto-fill: the first time
        ``pty_prompt`` appears in the terminal output, ``pty_response`` (plus a
        newline) is typed into the PTY — used to answer a remote ``sudo``
        password prompt without the secret ever touching a command line.

        Returns False if the connection is unknown, the command is empty, or the
        UI isn't ready. Valid after ``app_started``."""
        if self._host is None:
            return False
        return self._host.open_command_terminal(
            nickname, remote_command, title=title,
            pty_prompt=pty_prompt, pty_response=pty_response)

    # --- groups -------------------------------------------------------
    def create_group(self, name: str, color: Optional[str] = None) -> Optional[str]:
        """Find-or-create a sidebar group by display name; return its id
        (None if the UI isn't ready). Idempotent — safe to call on every
        provision. Valid after ``app_started``."""
        return self._host.ensure_group(name, color) if self._host is not None else None

    def add_connection_to_group(self, nickname: str, group_id: str) -> bool:
        """Add an existing connection to a group and refresh the sidebar.
        Returns False if unknown / UI not ready. Valid after ``app_started``."""
        return (self._host.add_connection_to_group(nickname, group_id)
                if self._host is not None else False)

    def add_connection_group(self, group_name: str,
                             connections: List[Dict[str, Any]],
                             color: Optional[str] = None):
        """Convenience for multi-node provisioning: ensure a group, create
        each connection (reusing any that already exist by nickname), assign
        them to the group, and refresh the sidebar ONCE. Returns
        ``(group_id, [ConnectionInfo])`` (group_id is None if the UI isn't
        ready). Valid after ``app_started``."""
        group_id = self.create_group(group_name, color)
        infos: List["ConnectionInfo"] = []
        for data in connections:
            nickname = (data.get("nickname") or data.get("host") or "").strip()
            try:
                infos.append(self.add_connection(data))
            except ValueError:
                existing = self.connection_manager.find_connection_by_nickname(nickname)
                if existing is not None:
                    infos.append(ConnectionInfo.from_connection(existing))
            if group_id and nickname and self._host is not None:
                self._host.add_connection_to_group(nickname, group_id, rebuild=False)
        if group_id and self._host is not None:
            self._host.rebuild_sidebar()
        return group_id, infos

    # --- keys ---------------------------------------------------------
    def generate_key(self, name: str, **kwargs) -> Optional[str]:
        """Generate an SSH key via the app's key manager and return its
        private-key path (None on failure). Accepts the same keyword args as
        the key manager (``key_type``, ``key_size``, ``comment``,
        ``passphrase``). Valid after ``app_started``."""
        return self._host.generate_key(name, **kwargs) if self._host is not None else None

    def list_keys(self) -> List[Dict[str, str]]:
        """List SSH keys known to the app's key manager as
        ``[{"private_path", "public_path"}]``. Empty if the UI isn't ready.
        Valid after ``app_started``."""
        return self._host.list_keys() if self._host is not None else []

    def delete_key(self, private_path: str) -> bool:
        """Delete an SSH key pair (private + ``.pub``) managed by the app. Only
        keys inside the app's key directory may be deleted; returns False
        otherwise or if the UI isn't ready. Valid after ``app_started``."""
        return (self._host.delete_key(private_path)
                if self._host is not None else False)

    # --- remote commands & ssh config ---------------------------------
    def run_command(self, nickname: str, command: str, *,
                    timeout: float = 30,
                    input: Optional[str] = None) -> "CommandResult":
        """Run a one-shot command on a saved connection and capture its output.

        Reuses the app's single SSH/auth path (``build_ssh_connection`` +
        ``resolve_native_auth`` via ``~/.ssh/config``), so ProxyJump, ports,
        identities and stored credentials all apply. **Blocking** — call from a
        worker thread and marshal UI work back via ``run_on_ui_thread``.
        Returns a ``CommandResult`` (``exit_code == -1`` means it could not be
        launched).

        ``input`` is written to the remote command's stdin (e.g. a password for
        ``sudo -S``); the SSH transport itself is non-interactive (no PTY)."""
        import os
        import subprocess
        from ..ssh_connection_builder import (
            ConnectionContext, build_ssh_connection)
        from .. import ssh_multiplex
        conn = self.connection_manager.find_connection_by_nickname(nickname)
        if conn is None:
            return CommandResult(-1, "", f"No connection named {nickname!r}")
        cleanup = None
        try:
            # When a surface has acquired multiplexing for this host, reuse its
            # ControlMaster socket: the first call opens the master, the rest ride
            # it (no re-auth). Transparent — callers don't opt in per-call.
            extra_args = (ssh_multiplex.controlmaster_args()
                          if ssh_multiplex.is_active(nickname) else None)
            ctx = ConnectionContext(
                connection=conn, connection_manager=self.connection_manager,
                config=self.config, command_type='ssh',
                remote_command=command, native_mode=True, extra_args=extra_args)
            prepared = build_ssh_connection(ctx)
            argv = list(prepared.command)
            env = {**os.environ, **(prepared.env or {})}
            # Password auth: feed via the shared sshpass FIFO (key/askpass auth
            # is already baked into env by build_ssh_connection).
            if prepared.use_sshpass and prepared.password:
                from ..ssh_password_exec import wrap_argv_with_sshpass
                argv, cleanup = wrap_argv_with_sshpass(
                    argv, prepared.password, env=env)
            result = subprocess.run(
                argv, env=env, capture_output=True, text=True,
                timeout=timeout, check=False, input=input)
            return CommandResult(result.returncode, result.stdout, result.stderr)
        except subprocess.TimeoutExpired:
            return CommandResult(-1, "", "Command timed out")
        except Exception as exc:  # noqa: BLE001 — surface as a failed result
            return CommandResult(-1, "", str(exc))
        finally:
            if cleanup is not None:
                cleanup()

    def acquire_multiplex(self, nickname: str) -> None:
        """Keep a shared SSH master (ControlMaster) warm for ``nickname`` while a
        surface is open, so its ``run_command`` calls reuse one connection instead
        of re-handshaking each time. Refcounted and reusable by any plugin —
        balance every call with :meth:`release_multiplex`. The master itself is
        created lazily by the first ``run_command`` (``ControlMaster=auto``)."""
        from .. import ssh_multiplex
        if nickname:
            ssh_multiplex.acquire(nickname)

    def release_multiplex(self, nickname: str) -> None:
        """Drop a multiplex reference acquired with :meth:`acquire_multiplex`. When
        the last reference goes away, tear the master down promptly via
        ``ssh -O exit`` (reusing the native auth path); if that fails the master
        expires on its own via ControlPersist."""
        from .. import ssh_multiplex
        if not nickname or not ssh_multiplex.release(nickname):
            return
        try:
            import os
            import subprocess
            from ..ssh_connection_builder import (
                ConnectionContext, build_ssh_connection)
            conn = self.connection_manager.find_connection_by_nickname(nickname)
            if conn is None:
                return
            ctx = ConnectionContext(
                connection=conn, connection_manager=self.connection_manager,
                config=self.config, command_type='ssh', native_mode=True,
                extra_args=["-O", "exit", "-o",
                            f"ControlPath={ssh_multiplex.control_path()}"])
            prepared = build_ssh_connection(ctx)
            env = {**os.environ, **(prepared.env or {})}
            subprocess.run(list(prepared.command), env=env, capture_output=True,
                           text=True, timeout=10, check=False)
        except Exception:  # noqa: BLE001 — best-effort teardown; ControlPersist
            pass               # expiry is the fallback, so failures are harmless

    def get_effective_ssh_config(self, nickname: str) -> Dict[str, Any]:
        """Return the resolved per-host SSH options for a connection, as
        computed by ``ssh -G`` (keys lowercased; multi-value options are
        lists). Reflects everything in ``~/.ssh/config`` for that host."""
        from ..ssh_config_utils import get_effective_ssh_config
        conn = self.connection_manager.find_connection_by_nickname(nickname)
        host = nickname
        if conn is not None:
            host = getattr(conn, "nickname", None) or getattr(conn, "host", None) or nickname
        return dict(get_effective_ssh_config(host) or {})

    def copy_key_to_host(self, nickname: str, public_key_path: str) -> bool:
        """Install a public key on a saved host via the app's ssh-copy-id path
        (reusing ``resolve_native_auth`` and ``~/.ssh/config``). **Blocking** —
        call from a worker thread. Returns True on success."""
        import os
        import subprocess
        from ..ssh_connection_builder import (
            _build_base_ssh_command, resolve_native_auth)
        from ..ssh_config_utils import get_effective_ssh_config
        conn = self.connection_manager.find_connection_by_nickname(nickname)
        if conn is None or not public_key_path:
            return False
        cleanup = None
        try:
            host = getattr(conn, "nickname", None) or getattr(conn, "host", None) or nickname
            effective = get_effective_ssh_config(host) or {}
            argv = _build_base_ssh_command(conn, effective, self.config,
                                           'ssh-copy-id')
            argv.extend(['-i', public_key_path])
            auth = resolve_native_auth(conn, self.connection_manager, self.config)
            argv.extend(auth.extra_opts or [])
            env = {**os.environ, **(auth.env or {})}
            if auth.use_sshpass and auth.password:
                from ..ssh_password_exec import wrap_argv_with_sshpass
                argv, cleanup = wrap_argv_with_sshpass(
                    argv, auth.password, env=env)
            result = subprocess.run(
                argv, env=env, capture_output=True, text=True, check=False)
            return result.returncode == 0
        except Exception:  # noqa: BLE001
            return False
        finally:
            if cleanup is not None:
                cleanup()

    # --- terminals / sessions -----------------------------------------
    def list_sessions(self) -> List["SessionInfo"]:
        """Return the currently open terminal sessions (empty if UI not ready).
        Each ``SessionInfo`` carries the connection and a ``session_id`` usable
        with ``read_terminal``/``send_terminal``. Valid after ``app_started``."""
        return self._host.list_sessions() if self._host is not None else []

    def read_terminal(self, session_id: str,
                      max_chars: Optional[int] = None) -> Optional[str]:
        """Read the visible/scrollback text of a session's terminal (up to
        ``max_chars`` from the end). None if the session is unknown or the UI
        isn't ready. Valid after ``app_started``."""
        return (self._host.read_terminal(session_id, max_chars)
                if self._host is not None else None)

    def send_terminal(self, session_id: str, text: str) -> bool:
        """Send ``text`` (as keyboard input) to a session's terminal. Returns
        False if the session is unknown or the UI isn't ready. Valid after
        ``app_started``."""
        return (self._host.send_terminal(session_id, text)
                if self._host is not None else False)

    # --- per-plugin data dir ------------------------------------------
    @property
    def data_dir(self) -> str:
        """Absolute path to a private, persistent directory for this plugin
        (created on first access). Backed by the XDG data dir."""
        return self._data_dir()

    def _data_dir(self) -> str:
        import os
        # Resolved from XDG_DATA_HOME directly (like the plugin loader's
        # _user_plugin_dir), so it sits beside the installed plugins and needs
        # no GTK/GLib at import time.
        base = os.environ.get("XDG_DATA_HOME") or os.path.join(
            os.path.expanduser("~"), ".local", "share")
        path = os.path.join(base, "sshpilot", "plugin-data", self.plugin_id)
        os.makedirs(path, exist_ok=True)
        return path

    # --- threading ----------------------------------------------------
    def run_on_ui_thread(self, fn: Callable, *args) -> None:
        """Schedule ``fn(*args)`` to run on the GTK main thread. Use this to
        return to the UI from a background worker (do network/provisioning
        work off-thread, then marshal UI/connection calls back here)."""
        if self._host is not None:
            self._host.run_on_ui_thread(fn, *args)
        else:
            fn(*args)

    # --- secrets (legacy explicit-id API; prefer ctx.secrets) ----------
    def _check_secret_args(self, plugin_id: str, key: str) -> None:
        if not plugin_id or '/' in plugin_id:
            raise ValueError(f"Invalid plugin id {plugin_id!r}")
        # A plugin may only touch its own namespace — the explicit id can't be
        # used to read/write another plugin's secrets. Use the scoped
        # ``ctx.secrets`` (auto-scoped) instead.
        if plugin_id != self.plugin_id:
            raise ValueError(
                "A plugin may only access its own secrets; use ctx.secrets")
        if not key:
            raise ValueError("Secret key must be non-empty")

    def get_secret(self, plugin_id: str, key: str) -> Optional[str]:
        """Namespaced keyring read. Prefer the scoped ``ctx.secrets.get``."""
        self._check_secret_args(plugin_id, key)
        return self.connection_manager.get_plugin_secret(plugin_id, key)

    def set_secret(self, plugin_id: str, key: str, value: str) -> None:
        """Prefer the scoped ``ctx.secrets.set``."""
        self._check_secret_args(plugin_id, key)
        if not self.connection_manager.store_plugin_secret(plugin_id, key, value):
            raise RuntimeError("No secure storage backend available")

    def delete_secret(self, plugin_id: str, key: str) -> bool:
        """Prefer the scoped ``ctx.secrets.delete``."""
        self._check_secret_args(plugin_id, key)
        return bool(self.connection_manager.delete_plugin_secret(plugin_id, key))


class SshPilotPlugin(abc.ABC):
    """Base class every plugin's entry point must subclass.

    The loader instantiates it with no arguments, then calls
    ``activate(ctx)`` once. ``deactivate()`` is best-effort (app quit or
    plugin disabled in preferences).
    """

    @abc.abstractmethod
    def activate(self, ctx: PluginContext) -> None: ...

    def deactivate(self) -> None:  # noqa: B027 (intentional no-op default)
        pass
