"""Local command execution for active agent (0.1.19 Phase 4)."""

from __future__ import annotations

from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from dataclasses import dataclass
import hashlib
import logging
import os
import platform
import re
import shutil
import sys
import time
from pathlib import Path
import subprocess
from typing import Any, Callable, Protocol
from urllib.parse import urlparse

from iuve_connect_agents.shared.feature_flags import (
    avatar_desktop_vision_enabled,
    avatar_summarize_cloud_enabled,
    avatar_web_fetch_device_local,
    avatar_web_fetch_enabled,
)

from .command_models import CommandEnvelope, ExecutionResult
from .xdotool_window_activate import run_windowactivate


logger = logging.getLogger(__name__)

DEFAULT_TIMEOUT_SEC = 30.0
MAX_COLLECT_LOG_LINES = 2000
MAX_READ_FILE_BYTES = 512 * 1024
ALLOWED_EDITOR_KEYS = frozenset({"default", "libreoffice", "vscode", "gedit"})

# Track live child processes so step timeouts can kill orphan xdotool/ydotool trees.
_ACTIVE_CHILD_PROCS: set[subprocess.Popen] = set()
_ACTIVE_CHILD_LOCK = __import__("threading").Lock()


def _register_child_proc(proc: subprocess.Popen) -> None:
    with _ACTIVE_CHILD_LOCK:
        _ACTIVE_CHILD_PROCS.add(proc)


def _unregister_child_proc(proc: subprocess.Popen) -> None:
    with _ACTIVE_CHILD_LOCK:
        _ACTIVE_CHILD_PROCS.discard(proc)


def _kill_process_tree(proc: subprocess.Popen | None) -> None:
    if proc is None or proc.poll() is not None:
        return
    try:
        if hasattr(os, "killpg"):
            try:
                os.killpg(proc.pid, 15)
            except (ProcessLookupError, PermissionError, OSError):
                proc.terminate()
        else:
            proc.terminate()
        try:
            proc.wait(timeout=2)
        except Exception:
            try:
                if hasattr(os, "killpg"):
                    os.killpg(proc.pid, 9)
                else:
                    proc.kill()
            except (ProcessLookupError, PermissionError, OSError):
                pass
    except Exception:
        logger.debug("kill_process_tree_failed", exc_info=True)
    finally:
        _unregister_child_proc(proc)


def kill_orphan_child_processes() -> int:
    """Terminate tracked subprocesses left after step timeout (xdotool/etc.)."""
    with _ACTIVE_CHILD_LOCK:
        procs = list(_ACTIVE_CHILD_PROCS)
    killed = 0
    for proc in procs:
        if proc.poll() is None:
            _kill_process_tree(proc)
            killed += 1
        else:
            _unregister_child_proc(proc)
    return killed


def run_subprocess_checked(
    cmd: list[str] | tuple[str, ...],
    *,
    timeout: float | None = 30,
    text: bool = True,
    capture_output: bool = True,
    start_new_session: bool = True,
    **kwargs: Any,
) -> subprocess.CompletedProcess:
    """subprocess.run equivalent that kills the process tree on timeout."""
    popen_kwargs: dict[str, Any] = {
        "stdout": subprocess.PIPE if capture_output else kwargs.pop("stdout", None),
        "stderr": subprocess.PIPE if capture_output else kwargs.pop("stderr", None),
        "text": text,
        "start_new_session": start_new_session,
        "close_fds": True,
    }
    popen_kwargs.update(kwargs)
    proc = subprocess.Popen(list(cmd), **popen_kwargs)
    _register_child_proc(proc)
    try:
        stdout, stderr = proc.communicate(timeout=timeout)
        completed = subprocess.CompletedProcess(
            args=list(cmd),
            returncode=int(proc.returncode or 0),
            stdout=stdout,
            stderr=stderr,
        )
        return completed
    except subprocess.TimeoutExpired as exc:
        _kill_process_tree(proc)
        raise subprocess.TimeoutExpired(cmd=list(cmd), timeout=timeout or 0, output=exc.output, stderr=exc.stderr) from exc
    finally:
        _unregister_child_proc(proc)
APPLICATION_LAUNCH_CANDIDATES: dict[str, tuple[tuple[str, ...], ...]] = {
    "cursor": (("cursor", "."), ("cursor",), ("gtk-launch", "cursor")),
    "telegram": (
        ("telegram-desktop",),
        ("gtk-launch", "org.telegram.desktop"),
        ("gtk-launch", "telegram-desktop"),
    ),
    "telegram-desktop": (
        ("telegram-desktop",),
        ("gtk-launch", "org.telegram.desktop"),
    ),
    "viber": (
        ("viber",),
        ("gtk-launch", "viber"),
        ("com.viber.Viber",),
    ),
    "claude": (("claude",), ("gtk-launch", "claude")),
    "firefox": (
        ("firefox",),
        ("firefox-esr",),
        ("gtk-launch", "firefox"),
        ("gtk-launch", "firefox-esr"),
        ("gtk-launch", "firefox_firefox"),
    ),
}
_BROWSER_WINDOW_CLASSES = (
    "google-chrome",
    "Google-chrome",
    "Chromium",
    "chromium",
    "firefox",
    "Firefox",
    "Brave-browser",
    "brave-browser",
)
_BROWSER_WINDOW_NAME_EXCLUDES = ("cursor",)


class RuntimeSnapshotView(Protocol):
    status: str
    failure_count: int
    last_error: str
    last_heartbeat_at: str


SnapshotProvider = Callable[[], RuntimeSnapshotView | None]
LogsPathProvider = Callable[[], str]
UpdateCheckFn = Callable[[], dict[str, Any]]
HandlerFn = Callable[[CommandEnvelope, str], ExecutionResult]
CloudFetchFn = Callable[[str], dict[str, Any]]
CloudSummarizeFn = Callable[[str, str], dict[str, Any]]
CloudGenerateFn = Callable[[str, str, str], dict[str, Any]]


@dataclass
class CommandExecutor:
    """Registry-backed executor for LOW and scoped MEDIUM workspace commands."""

    app_version: str
    snapshot_provider: SnapshotProvider | None = None
    logs_path_provider: LogsPathProvider | None = None
    update_check_fn: UpdateCheckFn | None = None
    cloud_fetch_fn: CloudFetchFn | None = None
    cloud_summarize_fn: CloudSummarizeFn | None = None
    cloud_generate_fn: CloudGenerateFn | None = None
    default_timeout_sec: float = DEFAULT_TIMEOUT_SEC

    def execute(self, envelope: CommandEnvelope, *, capability_key: str = "") -> ExecutionResult:
        command_type = envelope.command_type.strip().lower()
        handler = self._handlers().get(command_type)
        if handler is None:
            return ExecutionResult(
                success=False,
                execution_result="unsupported_command_type",
                error_message="unsupported_command_type",
                capability_key=capability_key,
            )
        step_timeout = self.default_timeout_sec
        if command_type == "keyboard_type_text":
            step_timeout = max(step_timeout, 90.0)
        elif command_type in {"generate_content", "insert_exact_text", "summarize_content", "verify_screen_contains_text", "verify_web_draft", "verify_published"}:
            step_timeout = max(step_timeout, 45.0)
        elif command_type == "open_web_composer":
            step_timeout = max(step_timeout, 90.0)
        try:
            result = self._run_with_timeout(
                lambda: handler(envelope, capability_key),
                timeout_sec=step_timeout,
            )
            if result.success:
                try:
                    from .goal_memory import goal_memory_store

                    goal_memory_store().record_from_execution(
                        command_type=command_type,
                        payload=dict(envelope.payload or {}),
                        evidence=dict(result.evidence or {}),
                    )
                except Exception:
                    pass
            return result
        except FuturesTimeoutError:
            kill_orphan_child_processes()
            return ExecutionResult(
                success=False,
                execution_result="timeout",
                error_message="execution_timeout",
                capability_key=capability_key,
            )
        except PermissionError as exc:
            return ExecutionResult(
                success=False,
                execution_result="path_outside_scope",
                error_message=str(exc) or "permission_denied:path_outside_scope",
                capability_key=capability_key,
            )
        except FileNotFoundError as exc:
            return ExecutionResult(
                success=False,
                execution_result="file_not_found",
                error_message=str(exc),
                capability_key=capability_key,
            )
        except OSError as exc:
            return ExecutionResult(
                success=False,
                execution_result="io_error",
                error_message=str(exc),
                capability_key=capability_key,
            )
        except ValueError as exc:
            return ExecutionResult(
                success=False,
                execution_result="invalid_request",
                error_message=str(exc),
                capability_key=capability_key,
            )
        except subprocess.TimeoutExpired as exc:
            kill_orphan_child_processes()
            return ExecutionResult(
                success=False,
                execution_result="timeout",
                error_message=str(exc) or "subprocess_timeout",
                capability_key=capability_key,
            )

    def _handlers(self) -> dict[str, HandlerFn]:
        return {
            "get_status": self._handle_get_status,
            "get_diagnostics": self._handle_get_diagnostics,
            "collect_logs": self._handle_collect_logs,
            "check_update": self._handle_check_update,
            "apply_update": self._handle_apply_update,
            "open_local_console_notification_only": self._handle_open_notification,
            "read_file_in_workspace": self._handle_read_file,
            "write_file_in_workspace": self._handle_write_file,
            "restart_agent": self._handle_restart_agent,
            "browser_open_url": self._handle_browser_open_url,
            "open_url": self._handle_browser_open_url,
            "open_document_edit": self._handle_open_document_edit,
            "service_restart": self._handle_service_restart,
            "network_diagnostics": self._handle_network_diagnostics,
            "package_install_dry_run": self._handle_package_install_dry_run,
            "web_fetch_bounded": self._handle_web_fetch_bounded,
            "summarize_content": self._handle_summarize_content,
            "generate_content": self._handle_generate_content,
            "insert_exact_text": self._handle_insert_exact_text,
            "verify_workspace_file": self._handle_verify_workspace_file,
            "social_post_draft": self._handle_social_post_draft,
            "email_compose": self._handle_email_compose,
            "social_post_publish": self._handle_social_post_publish,
            "browser_click_publish": self._handle_browser_click_publish,
            "email_send": self._handle_email_send,
            "mouse_move_relative": self._handle_mouse_move_relative,
            "empty_trash": self._handle_empty_trash,
            "open_folder": self._handle_open_folder,
            "application_launch": self._handle_application_launch,
            "keyboard_type_text": self._handle_keyboard_type_text,
            "capture_screenshot": self._handle_capture_screenshot,
            "verify_screen_contains_text": self._handle_verify_screen_contains_text,
            "verify_web_draft": self._handle_verify_web_draft,
            "verify_published": self._handle_verify_published,
            "open_web_composer": self._handle_open_web_composer,
            "describe_active_window": self._handle_describe_active_window,
            "list_files_in_folder": self._handle_list_files_in_folder,
        }

    @staticmethod
    def _run_with_timeout(fn: Callable[[], ExecutionResult], *, timeout_sec: float) -> ExecutionResult:
        if timeout_sec <= 0:
            return fn()
        with ThreadPoolExecutor(max_workers=1) as pool:
            future = pool.submit(fn)
            return future.result(timeout=timeout_sec)

    def _handle_get_status(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        snapshot = self.snapshot_provider() if self.snapshot_provider else None
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={
                "runtime_status": getattr(snapshot, "status", "Unknown"),
                "last_heartbeat_at": getattr(snapshot, "last_heartbeat_at", ""),
                "failure_count": getattr(snapshot, "failure_count", 0),
            },
        )

    def _handle_get_diagnostics(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        snapshot = self.snapshot_provider() if self.snapshot_provider else None
        memory_summary = self._read_memory_summary()
        cpu_summary = self._read_cpu_summary()
        disk_summary = self._read_disk_summary()
        format_table = bool(envelope.payload.get("format_table"))
        metrics_table = ""
        if format_table or envelope.payload.get("include_cpu") or envelope.payload.get("include_disk"):
            metrics_table = self._format_metrics_table(
                cpu_summary=cpu_summary,
                memory_summary=memory_summary,
                disk_summary=disk_summary,
            )
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={
                "runtime_status": getattr(snapshot, "status", "Unknown"),
                "last_error": getattr(snapshot, "last_error", ""),
                "failure_count": getattr(snapshot, "failure_count", 0),
                "heartbeat_at": getattr(snapshot, "last_heartbeat_at", ""),
                "memory_summary": memory_summary,
                "cpu_summary": cpu_summary,
                "disk_summary": disk_summary,
                "metrics_table": metrics_table,
                "handler": "get_diagnostics",
            },
        )

    @staticmethod
    def _read_cpu_summary() -> dict[str, Any]:
        try:
            with open("/proc/stat", encoding="utf-8") as handle:
                line = handle.readline()
        except OSError:
            return {"cpu_percent": None, "available": False}
        parts = line.split()
        if len(parts) < 5 or parts[0] != "cpu":
            return {"cpu_percent": None, "available": False}
        try:
            values = [int(part) for part in parts[1:]]
        except ValueError:
            return {"cpu_percent": None, "available": False}
        idle = values[3] + (values[4] if len(values) > 4 else 0)
        total = sum(values)
        if total <= 0:
            return {"cpu_percent": None, "available": False}
        used_pct = round(100.0 * (1.0 - idle / total), 1)
        return {"cpu_percent": used_pct, "available": True}

    @staticmethod
    def _read_disk_summary() -> dict[str, Any]:
        try:
            usage = shutil.disk_usage(str(Path.home()))
        except OSError:
            return {"available": False}
        free_gb = round(usage.free / (1024**3), 2)
        total_gb = round(usage.total / (1024**3), 2)
        used_gb = round((usage.total - usage.free) / (1024**3), 2)
        return {
            "available": True,
            "disk_free_gb": free_gb,
            "disk_total_gb": total_gb,
            "disk_used_gb": used_gb,
        }

    @staticmethod
    def _format_metrics_table(
        *,
        cpu_summary: dict[str, Any],
        memory_summary: dict[str, Any],
        disk_summary: dict[str, Any],
    ) -> str:
        rows: list[tuple[str, str, str]] = []
        cpu_val = cpu_summary.get("cpu_percent")
        if cpu_val is None:
            rows.append(("CPU", "—", "недоступно"))
        else:
            rows.append(("CPU", f"{cpu_val}%", "загрузка"))
        mem_total = memory_summary.get("mem_total_kb")
        mem_avail = memory_summary.get("mem_available_kb")
        if not mem_total:
            rows.append(("RAM", "—", "недоступно"))
        else:
            used_kb = max(int(mem_total) - int(mem_avail or 0), 0)
            used_gb = round(used_kb / (1024 * 1024), 2)
            total_gb = round(int(mem_total) / (1024 * 1024), 2)
            rows.append(("RAM", f"{used_gb} / {total_gb} GB", "использовано / всего"))
        if not disk_summary.get("available"):
            rows.append(("Диск", "—", "недоступно"))
        else:
            free_gb = disk_summary.get("disk_free_gb")
            total_gb = disk_summary.get("disk_total_gb")
            rows.append(("Диск", f"{free_gb} / {total_gb} GB", "свободно / всего"))
        lines = ["| Метрика | Значение | Примечание |", "|---|---|---|"]
        lines.extend(f"| {metric} | {value} | {note} |" for metric, value, note in rows)
        return "\n".join(lines)

    @staticmethod
    def _read_memory_summary() -> dict[str, Any]:
        meminfo_path = Path("/proc/meminfo")
        if not meminfo_path.is_file():
            return {"available": "unknown"}
        values: dict[str, int] = {}
        for line in meminfo_path.read_text(encoding="utf-8", errors="replace").splitlines():
            if ":" not in line:
                continue
            key, raw = line.split(":", 1)
            token = raw.strip().split()[0]
            if token.isdigit():
                values[key.strip()] = int(token)
        mem_total = values.get("MemTotal", 0)
        mem_available = values.get("MemAvailable", values.get("MemFree", 0))
        return {
            "mem_total_kb": mem_total,
            "mem_available_kb": mem_available,
            "mem_used_kb": max(mem_total - mem_available, 0),
            "memory_free_gb": round(mem_available / (1024 * 1024), 2) if mem_total else 0,
            "memory_total_gb": round(mem_total / (1024 * 1024), 2) if mem_total else 0,
            "memory_used_gb": round(max(mem_total - mem_available, 0) / (1024 * 1024), 2)
            if mem_total
            else 0,
        }

    def _handle_collect_logs(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        max_lines = min(int(envelope.payload.get("max_lines") or 200), MAX_COLLECT_LOG_LINES)
        logs_path = self._resolve_logs_path()
        if not logs_path.is_file():
            return ExecutionResult(
                success=True,
                capability_key=capability_key,
                evidence={
                    "log_collection": "log_file_missing",
                    "logs_path": str(logs_path),
                    "max_lines": max_lines,
                    "lines": [],
                },
            )
        lines = self._tail_file(logs_path, max_lines=max_lines)
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={
                "log_collection": "ok",
                "logs_path": str(logs_path),
                "max_lines": max_lines,
                "line_count": len(lines),
                "lines": lines,
            },
        )

    def _handle_check_update(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        if self.update_check_fn is not None:
            update_result = self.update_check_fn()
            return ExecutionResult(
                success=bool(update_result.get("ok", True)),
                capability_key=capability_key,
                evidence={
                    "current_version": str(update_result.get("current_version") or self.app_version),
                    "update_available": bool(update_result.get("would_upgrade", False)),
                    "status": str(update_result.get("status") or ""),
                    "reason": str(update_result.get("reason") or ""),
                    "target_version": str(update_result.get("target_version") or ""),
                },
            )
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={
                "current_version": self.app_version,
                "update_available": False,
            },
        )

    def _handle_open_notification(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        message = str(envelope.payload.get("message") or "Open local console from tray menu")
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={"notification": message},
        )

    def _handle_read_file(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        target = self._resolve_workspace_target(envelope)
        content = target.read_bytes()
        if len(content) > MAX_READ_FILE_BYTES:
            raise ValueError("file_too_large")
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={
                "path": str(target),
                "size_bytes": len(content),
                "content": content.decode("utf-8", errors="replace"),
            },
        )

    def _handle_write_file(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        target = self._resolve_workspace_target(envelope)
        routing_error = self._enforce_routing_write_path(envelope, target)
        if routing_error is not None:
            return routing_error
        content = envelope.payload.get("content")
        if content is None:
            raise ValueError("missing_content")
        text = str(content)
        if len(text.encode("utf-8")) > MAX_READ_FILE_BYTES:
            raise ValueError("content_too_large")
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(text, encoding="utf-8")
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={
                "path": str(target),
                "bytes_written": len(text.encode("utf-8")),
            },
        )

    def _handle_restart_agent(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        payload = envelope.payload
        return ExecutionResult(
            success=True,
            capability_key=capability_key,
            evidence={
                "policy": "manual_restart_required",
                "confirm_restart": bool(payload.get("confirm_restart", True)),
                "approval_ticket": str(payload.get("approval_ticket") or ""),
            },
        )

    def _handle_browser_open_url(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        url = str(envelope.payload.get("url") or "").strip()
        if not url:
            raise ValueError("missing_url")
        parsed = urlparse(url)
        if parsed.scheme not in {"http", "https"}:
            raise ValueError("invalid_url_scheme")

        # Shared cross-OS facade (Win startfile / Mac open / Linux adapter|xdg-open).
        try:
            from ..platform_adapters.desktop_shell import open_url as shell_open_url

            shell = shell_open_url(url)
            if shell.success:
                return ExecutionResult(
                    success=True,
                    execution_result="ok",
                    capability_key=capability_key,
                    evidence={
                        "url": url,
                        "browser": str(envelope.payload.get("browser") or "default"),
                        "app_id": str(envelope.payload.get("app_id") or "web_browser"),
                        "launch_command": shell.command,
                        "launch_pid": shell.pid,
                        "backend": shell.backend,
                        "handler": "browser_open_url",
                    },
                )
            shell_error = shell.error or "browser_launch_failed"
        except Exception as exc:  # noqa: BLE001 — fall back to adapter path
            shell_error = str(exc)

        adapter = self._get_app_adapter()
        if adapter is not None and hasattr(adapter, "launch_uri"):
            launch = adapter.launch_uri(url)
            if launch.success:
                return ExecutionResult(
                    success=True,
                    execution_result="ok",
                    capability_key=capability_key,
                    evidence={
                        "url": url,
                        "browser": str(envelope.payload.get("browser") or "default"),
                        "app_id": str(envelope.payload.get("app_id") or "web_browser"),
                        "launch_command": launch.launch_command,
                        "launch_pid": launch.pid,
                        "handler": "browser_open_url",
                    },
                )
            return ExecutionResult(
                success=False,
                execution_result="launch_failed",
                capability_key=capability_key,
                evidence={"url": url, "launch_command": launch.launch_command},
                error_message=launch.error or shell_error or "browser_launch_failed",
            )
        return ExecutionResult(
            success=False,
            execution_result="launch_failed",
            capability_key=capability_key,
            evidence={"url": url},
            error_message=shell_error or "browser_launch_failed",
        )

    def _handle_open_document_edit(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        target = self._resolve_workspace_target(envelope)
        content = envelope.payload.get("content")
        mode = str(envelope.payload.get("mode") or "open").strip().lower()
        editor = str(envelope.payload.get("editor") or "default").strip().lower()
        if editor not in ALLOWED_EDITOR_KEYS:
            raise ValueError("editor_not_allowlisted")

        bytes_written = 0
        if content is not None and mode in {"edit", "append", "open"}:
            text = str(content)
            if mode == "append" and target.is_file():
                existing = target.read_text(encoding="utf-8", errors="replace")
                text = f"{existing}{text}"
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(text, encoding="utf-8")
            bytes_written = len(text.encode("utf-8"))

        if not target.is_file():
            raise FileNotFoundError(f"missing_document:{target}")

        launch_cmd = self._editor_launch_command(editor, target)
        try:
            launch_pid = self._spawn_detached(launch_cmd)
        except FileNotFoundError:
            return ExecutionResult(
                success=False,
                execution_result="editor_launch_failed",
                exit_code=127,
                capability_key=capability_key,
                evidence={
                    "path": str(target),
                    "editor": editor,
                    "bytes_written": bytes_written,
                    "launch_command": launch_cmd,
                },
                error_message="editor_launch_failed",
            )
        return ExecutionResult(
            success=True,
            execution_result="ok",
            exit_code=0,
            capability_key=capability_key,
            evidence={
                "path": str(target),
                "editor": editor,
                "bytes_written": bytes_written,
                "launch_command": launch_cmd,
                "launch_pid": launch_pid,
                "launch_mode": "detached",
            },
            error_message="",
        )

    def _handle_service_restart(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        service_name = str(envelope.payload.get("service_name") or "").strip()
        if not service_name:
            raise ValueError("missing_service_name")
        proc = subprocess.run(
            ["systemctl", "--user", "restart", service_name],
            check=False,
            capture_output=True,
            text=True,
            timeout=30,
        )
        return ExecutionResult(
            success=proc.returncode == 0,
            execution_result="ok" if proc.returncode == 0 else "service_restart_failed",
            exit_code=int(proc.returncode),
            capability_key=capability_key,
            evidence={
                "service_name": service_name,
                "stdout": (proc.stdout or "")[:500],
                "stderr": (proc.stderr or "")[:500],
            },
            error_message="" if proc.returncode == 0 else "service_restart_failed",
        )

    def _handle_network_diagnostics(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        target = str(
            envelope.payload.get("target")
            or envelope.payload.get("host")
            or "iuve.eu"
        ).strip()
        proc = subprocess.run(
            ["ping", "-c", "3", "-W", "3", target],
            check=False,
            capture_output=True,
            text=True,
            timeout=20,
        )
        return ExecutionResult(
            success=proc.returncode == 0,
            execution_result="ok" if proc.returncode == 0 else "network_check_failed",
            exit_code=int(proc.returncode),
            capability_key=capability_key,
            evidence={
                "target": target,
                "stdout": (proc.stdout or "")[:2000],
                "stderr": (proc.stderr or "")[:500],
            },
            error_message="" if proc.returncode == 0 else "network_check_failed",
        )

    def _handle_package_install_dry_run(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        package_name = str(envelope.payload.get("package_name") or "").strip()
        if not package_name:
            raise ValueError("missing_package_name")
        proc = subprocess.run(
            ["apt-get", "install", "--dry-run", package_name],
            check=False,
            capture_output=True,
            text=True,
            timeout=60,
        )
        return ExecutionResult(
            success=True,
            execution_result="dry_run",
            exit_code=int(proc.returncode),
            capability_key=capability_key,
            evidence={
                "package_name": package_name,
                "stdout": (proc.stdout or "")[:4000],
                "stderr": (proc.stderr or "")[:500],
                "dry_run": True,
            },
        )

    def _handle_web_fetch_bounded(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        if not avatar_web_fetch_enabled():
            return ExecutionResult(
                success=False,
                execution_result="web_fetch_disabled",
                error_message="web_fetch_disabled",
                capability_key=capability_key,
            )

        url = str(envelope.payload.get("url") or "").strip()
        if not url:
            raise ValueError("missing_url")
        parsed = urlparse(url)
        if parsed.scheme not in {"http", "https"}:
            raise ValueError("invalid_url_scheme")

        if avatar_web_fetch_device_local() or self.cloud_fetch_fn is None:
            from .web_fetch_local import fetch_url_bounded_local

            fetch_result = fetch_url_bounded_local(url)
        else:
            fetch_result = self.cloud_fetch_fn(url)

        if not bool(fetch_result.get("ok")):
            return ExecutionResult(
                success=False,
                execution_result="fetch_failed",
                error_message=str(fetch_result.get("error") or "fetch_failed"),
                capability_key=capability_key,
                evidence={
                    "url": url,
                    "error": str(fetch_result.get("error") or "fetch_failed"),
                },
            )

        text = str(fetch_result.get("text") or "")
        text_length = int(fetch_result.get("text_length") or len(text))
        content_hash = str(fetch_result.get("content_hash") or hashlib.sha256(text.encode("utf-8")).hexdigest())
        return ExecutionResult(
            success=text_length > 0,
            execution_result="ok" if text_length > 0 else "empty_content",
            capability_key=capability_key,
            evidence={
                "url": str(fetch_result.get("url") or url),
                "text": text,
                "text_length": text_length,
                "content_hash": content_hash,
                "truncated": bool(fetch_result.get("truncated", False)),
            },
            error_message="" if text_length > 0 else "empty_content",
        )

    def _handle_summarize_content(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        content = str(
            envelope.payload.get("content")
            or envelope.payload.get("text")
            or envelope.payload.get("summary")
            or ""
        ).strip()
        if not content:
            raise ValueError("missing_content")

        goal_summary = str(envelope.payload.get("goal_summary") or envelope.payload.get("goal") or "")
        summary = ""
        mode = "rules_template"
        error = ""

        if avatar_summarize_cloud_enabled() and self.cloud_summarize_fn is not None:
            cloud_result = self.cloud_summarize_fn(content, goal_summary)
            if bool(cloud_result.get("ok")):
                summary = str(cloud_result.get("summary") or "").strip()
                mode = str(cloud_result.get("mode") or "cloud")
            else:
                error = str(cloud_result.get("error") or "")

        if not summary:
            summary = self._rules_summarize(content, goal_summary=goal_summary)
            mode = "rules_template"

        present_to_chat = bool(envelope.payload.get("present_to_chat"))
        verify_mode = str(envelope.payload.get("verify_mode") or "").strip()
        min_len = 20 if not present_to_chat else 1
        summary_hash = hashlib.sha256(summary.encode("utf-8")).hexdigest()
        return ExecutionResult(
            success=len(summary) >= min_len,
            execution_result="ok" if len(summary) >= min_len else "summary_too_short",
            capability_key=capability_key,
            evidence={
                "summary": summary,
                "summary_length": len(summary),
                "content_hash": summary_hash,
                "source_text_length": len(content),
                "mode": mode,
                "present_to_chat": present_to_chat,
                "verify_mode": verify_mode or ("research_completed" if present_to_chat else ""),
                **({"cloud_error": error} if error else {}),
            },
            error_message="" if len(summary) >= min_len else "summary_too_short",
        )

    def _handle_generate_content(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        content_type = str(envelope.payload.get("content_type") or "document").strip().lower()
        topic = str(envelope.payload.get("topic") or envelope.payload.get("subject") or "").strip()
        goal_summary = str(envelope.payload.get("goal_summary") or envelope.payload.get("goal") or topic)
        research_context = str(
            envelope.payload.get("research_context")
            or envelope.payload.get("research_context_md")
            or ""
        ).strip()
        literal_content = str(envelope.payload.get("literal_content") or "").strip()
        verbatim = bool(envelope.payload.get("verbatim")) or bool(literal_content)
        generated = ""
        mode = "rules_template"
        error = ""

        if verbatim:
            generated = literal_content or topic
            mode = "verbatim"
        elif avatar_summarize_cloud_enabled() and self.cloud_generate_fn is not None:
            cloud_result = self.cloud_generate_fn(content_type, topic, goal_summary)
            if bool(cloud_result.get("ok")):
                generated = str(cloud_result.get("content") or cloud_result.get("generated_content") or "").strip()
                mode = str(cloud_result.get("mode") or "cloud")
            else:
                error = str(cloud_result.get("error") or "")

        if not generated and not verbatim:
            generated = self._rules_generate_content(
                content_type,
                topic=topic or goal_summary,
                research_context=research_context,
            )
            mode = "rules_template"

        max_chars_raw = envelope.payload.get("max_chars")
        if max_chars_raw is not None:
            try:
                max_chars = int(max_chars_raw)
            except (TypeError, ValueError):
                max_chars = 0
            if max_chars > 0 and len(generated) > max_chars:
                generated = generated[: max_chars - 1].rstrip() + "…"

        min_len = 1 if verbatim else (40 if content_type == "html_landing" else 20)
        content_hash = hashlib.sha256(generated.encode("utf-8")).hexdigest()
        return ExecutionResult(
            success=len(generated) >= min_len,
            execution_result="ok" if len(generated) >= min_len else "content_too_short",
            capability_key=capability_key,
            evidence={
                "content": generated,
                "generated_content": generated,
                "generated_text": generated,
                "text": generated,
                "content_length": len(generated),
                "content_hash": content_hash,
                "content_type": content_type,
                "topic": topic or goal_summary,
                "mode": mode,
                "handler": "generate_content",
                **({"cloud_error": error} if error else {}),
            },
            error_message="" if len(generated) >= min_len else "content_too_short",
        )

    def _handle_insert_exact_text(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        """Pass through user-provided post body — no LLM generation (0.2.2.48)."""
        exact = str(
            envelope.payload.get("exact_text")
            or envelope.payload.get("literal_content")
            or envelope.payload.get("text")
            or ""
        ).strip()
        content_type = str(envelope.payload.get("content_type") or "social_post").strip().lower()
        content_hash = hashlib.sha256(exact.encode("utf-8")).hexdigest() if exact else ""
        return ExecutionResult(
            success=bool(exact),
            execution_result="exact_text_ready" if exact else "exact_text_missing",
            capability_key=capability_key,
            evidence={
                "content": exact,
                "generated_content": exact,
                "generated_text": exact,
                "text": exact,
                "exact_text": exact,
                "content_length": len(exact),
                "content_hash": content_hash,
                "content_type": content_type,
                "topic": exact,
                "mode": "exact",
                "generate_content": False,
                "handler": "insert_exact_text",
            },
            error_message="" if exact else "exact_text_missing",
        )

    def _handle_verify_workspace_file(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        target = self._resolve_workspace_target(envelope)
        if not target.is_file():
            return ExecutionResult(
                success=False,
                execution_result="file_not_found",
                capability_key=capability_key,
                evidence={
                    "path": str(target),
                    "exists": False,
                    "handler": "verify_workspace_file",
                    "verify_mode": str(envelope.payload.get("verify_mode") or "exists"),
                },
                error_message="file_not_found",
            )
        content = target.read_text(encoding="utf-8", errors="replace")
        topic = str(envelope.payload.get("topic") or "").strip().lower()
        verify_mode = str(envelope.payload.get("verify_mode") or "exists").strip().lower()
        html_ok = True
        topic_ok = True
        if verify_mode in {"html", "html_structure"} or str(target).lower().endswith(".html"):
            lower = content.lower()
            html_ok = "<!doctype" in lower or "<html" in lower
            if topic:
                topic_ok = topic in lower or any(word in lower for word in topic.split() if len(word) > 3)
        passed = html_ok and topic_ok
        return ExecutionResult(
            success=passed,
            execution_result="verified" if passed else "verify_failed",
            capability_key=capability_key,
            evidence={
                "path": str(target),
                "exists": True,
                "size_bytes": len(content.encode("utf-8")),
                "html_valid": html_ok,
                "topic_found": topic_ok,
                "verify_mode": verify_mode,
                "handler": "verify_workspace_file",
                "pass": passed,
            },
            error_message="" if passed else "verify_failed",
        )

    def _handle_apply_update(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        dry_run = bool(envelope.payload.get("dry_run", True))
        if self.update_check_fn is None:
            return ExecutionResult(
                success=False,
                execution_result="update_check_unavailable",
                error_message="update_check_unavailable",
                capability_key=capability_key,
            )
        update_result = self.update_check_fn()
        if dry_run:
            return ExecutionResult(
                success=bool(update_result.get("ok", True)),
                execution_result="dry_run",
                capability_key=capability_key,
                evidence={
                    "dry_run": True,
                    "current_version": str(update_result.get("current_version") or self.app_version),
                    "update_available": bool(update_result.get("would_upgrade", False)),
                    "target_version": str(update_result.get("target_version") or ""),
                    "status": str(update_result.get("status") or ""),
                },
            )
        return ExecutionResult(
            success=False,
            execution_result="manual_update_required",
            capability_key=capability_key,
            evidence={
                "policy": "signed_remote_apply_update_requires_console_upgrade",
                "current_version": str(update_result.get("current_version") or self.app_version),
                "update_available": bool(update_result.get("would_upgrade", False)),
                "target_version": str(update_result.get("target_version") or ""),
            },
            error_message="apply_update_requires_manual_console_upgrade",
        )

    def _handle_social_post_draft(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        draft = str(envelope.payload.get("content") or envelope.payload.get("text") or "").strip()
        if not draft:
            draft = "Draft social post prepared by IuVe Avatar (not published)."
        return ExecutionResult(
            success=True,
            execution_result="draft_prepared",
            capability_key=capability_key,
            evidence={"draft": draft, "published": False, "handler": "social_post_draft"},
        )

    def _handle_email_compose(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        body = str(envelope.payload.get("body") or envelope.payload.get("content") or "").strip()
        subject = str(envelope.payload.get("subject") or "Draft").strip()
        if not body:
            body = "Draft email prepared by IuVe Avatar (not sent)."
        return ExecutionResult(
            success=True,
            execution_result="draft_prepared",
            capability_key=capability_key,
            evidence={"subject": subject, "body": body, "sent": False, "handler": "email_compose"},
        )

    def _handle_social_post_publish(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        content = str(envelope.payload.get("content") or envelope.payload.get("text") or "").strip()
        platform = str(envelope.payload.get("platform") or "social").strip()
        return ExecutionResult(
            success=True,
            execution_result="publish_stub",
            capability_key=capability_key,
            evidence={
                "platform": platform,
                "content_preview": content[:240],
                "published": True,
                "handler": "social_post_publish",
            },
        )

    def _handle_browser_click_publish(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        payload = dict(envelope.payload or {})
        button_label = str(payload.get("button_label") or "Опубликовать").strip()
        deny_message = str(
            payload.get("deny_message") or "Черновик готов. Для публикации требуется подтверждение."
        ).strip()
        confirm_modal = str(payload.get("confirm_modal_text") or "Опубликовать сейчас?").strip()
        if not bool(payload.get("publish_confirmed")):
            return ExecutionResult(
                success=False,
                execution_result="publish_confirmation_required",
                error_message=deny_message,
                capability_key=capability_key,
                evidence={
                    "stop_zone": "publish",
                    "requires_user_confirm": True,
                    "button_label": button_label,
                    "confirm_modal_text": confirm_modal,
                    "deny_message": deny_message,
                    "publish_requested": True,
                    "publish_confirmation_required": True,
                    "draft_state": "publish_confirmation_required",
                    "handler": "browser_click_publish",
                },
            )

        from .web_operator import PUBLISH_BUTTON_LABELS

        xdotool = shutil.which("xdotool")
        clicked = False
        detail = "publish_button_not_found"
        if xdotool:
            window_name = str(payload.get("window_name") or "").strip()
            focus_ok, focus_detail, _focus_meta = self._xdotool_focus_target(
                xdotool,
                target_app="browser",
                window_name=window_name,
            )
            if focus_ok:
                for label in (button_label, *PUBLISH_BUTTON_LABELS):
                    if not label:
                        continue
                    for key_seq in ([f"Tab"], ["shift+Tab"]):
                        tab = subprocess.run(
                            [xdotool, "key", "--clearmodifiers", *key_seq],
                            capture_output=True,
                            text=True,
                            timeout=10,
                            check=False,
                        )
                        if tab.returncode != 0:
                            continue
                        time.sleep(0.1)
                    click = subprocess.run(
                        [xdotool, "key", "--clearmodifiers", "Return"],
                        capture_output=True,
                        text=True,
                        timeout=10,
                        check=False,
                    )
                    clicked = click.returncode == 0
                    if clicked:
                        detail = "publish_clicked"
                        break
                if not clicked:
                    detail = "publish_button_not_found"
            else:
                detail = str(focus_detail or "browser_focus_failed")

        return ExecutionResult(
            success=clicked,
            execution_result="publish_clicked_pending_verify" if clicked else "publish_failed",
            capability_key=capability_key,
            evidence={
                "button_label": button_label,
                "published": False,
                "publish_clicked": clicked,
                "publish_verified": False,
                "publish_failed": not clicked,
                "detail": detail,
                "draft_state": "publish_clicked_pending_verify" if clicked else "publish_failed",
                "web_post_status": "publish_clicked_pending_verify" if clicked else "publish_failed",
                "handler": "browser_click_publish",
            },
            error_message="" if clicked else detail,
        )

    def _handle_email_send(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        recipient = str(envelope.payload.get("to") or envelope.payload.get("recipient") or "").strip()
        return ExecutionResult(
            success=True,
            execution_result="send_stub",
            capability_key=capability_key,
            evidence={
                "recipient": recipient,
                "sent": True,
                "handler": "email_send",
            },
        )

    def _handle_mouse_move_relative(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        dx = int(envelope.payload.get("dx") or 0)
        dy = int(envelope.payload.get("dy") or 0)
        xdotool = shutil.which("xdotool")
        ydotool = shutil.which("ydotool")
        if xdotool:
            proc = subprocess.run(
                [xdotool, "mousemove_relative", "--", str(dx), str(dy)],
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
            if proc.returncode != 0:
                return ExecutionResult(
                    success=False,
                    execution_result="mouse_tool_failed",
                    error_message=(proc.stderr or proc.stdout or "xdotool_failed").strip(),
                    capability_key=capability_key,
                )
            return ExecutionResult(
                success=True,
                execution_result="mouse_moved",
                capability_key=capability_key,
                evidence={"dx": dx, "dy": dy, "tool": "xdotool", "handler": "mouse_move_relative"},
            )
        if ydotool:
            proc = subprocess.run(
                [ydotool, "mousemove", "--", str(dx), str(dy)],
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
            if proc.returncode != 0:
                return ExecutionResult(
                    success=False,
                    execution_result="mouse_tool_failed",
                    error_message=(proc.stderr or proc.stdout or "ydotool_failed").strip(),
                    capability_key=capability_key,
                )
            return ExecutionResult(
                success=True,
                execution_result="mouse_moved",
                capability_key=capability_key,
                evidence={"dx": dx, "dy": dy, "tool": "ydotool", "handler": "mouse_move_relative"},
            )
        return ExecutionResult(
            success=False,
            execution_result="mouse_tool_missing",
            error_message="mouse_tool_missing: install xdotool or ydotool",
            capability_key=capability_key,
        )

    def _handle_empty_trash(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        adapter = self._get_app_adapter()
        if adapter is not None and hasattr(adapter, "empty_trash"):
            result = adapter.empty_trash()
            if result.success:
                return ExecutionResult(
                    success=True,
                    execution_result=result.execution_result or "trash_emptied",
                    capability_key=capability_key,
                    evidence=dict(result.evidence),
                )
            return ExecutionResult(
                success=False,
                execution_result=result.execution_result or "trash_empty_failed",
                error_message=result.error or "trash_empty_failed",
                capability_key=capability_key,
                evidence=dict(result.evidence),
            )

        gio = shutil.which("gio")
        if gio:
            proc = subprocess.run(
                [gio, "trash", "--empty"],
                capture_output=True,
                text=True,
                timeout=30,
                check=False,
            )
            if proc.returncode != 0:
                return ExecutionResult(
                    success=False,
                    execution_result="trash_empty_failed",
                    error_message=(proc.stderr or proc.stdout or "gio_trash_empty_failed").strip(),
                    capability_key=capability_key,
                )
            return ExecutionResult(
                success=True,
                execution_result="trash_emptied",
                capability_key=capability_key,
                evidence={"tool": "gio", "handler": "empty_trash"},
            )

        trash_files = Path.home() / ".local" / "share" / "Trash" / "files"
        trash_info = Path.home() / ".local" / "share" / "Trash" / "info"
        removed = 0
        for trash_dir in (trash_files, trash_info):
            if not trash_dir.is_dir():
                continue
            for entry in trash_dir.iterdir():
                if entry.is_file() or entry.is_symlink():
                    entry.unlink(missing_ok=True)
                    removed += 1
                elif entry.is_dir():
                    shutil.rmtree(entry, ignore_errors=True)
                    removed += 1
        return ExecutionResult(
            success=True,
            execution_result="trash_emptied",
            capability_key=capability_key,
            evidence={"tool": "xdg_fallback", "entries_removed": removed, "handler": "empty_trash"},
        )

    def _handle_open_folder(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        raw_path = str(
            envelope.payload.get("absolute_path")
            or envelope.payload.get("path")
            or ""
        ).strip()
        if raw_path:
            target = Path(raw_path).expanduser()
        else:
            target = self._resolve_workspace_target(envelope)
        target.mkdir(parents=True, exist_ok=True)
        if not target.is_dir():
            raise ValueError("not_a_directory")

        adapter = self._get_app_adapter()
        if adapter is not None:
            launch = adapter.open_folder(str(target))
            if launch.success:
                return ExecutionResult(
                    success=True,
                    execution_result="folder_opened",
                    capability_key=capability_key,
                    evidence={
                        "path": str(target),
                        "launch_command": launch.launch_command,
                        "launch_pid": launch.pid,
                        "launch_mode": "detached",
                        "handler": "open_folder",
                        "app_id": str(envelope.payload.get("app_id") or "file_manager"),
                    },
                )
            return ExecutionResult(
                success=False,
                execution_result="folder_open_failed",
                capability_key=capability_key,
                evidence={"path": str(target), "launch_command": launch.launch_command},
                error_message="folder_open_failed",
            )

        launch_cmd = ["xdg-open", str(target)]
        try:
            launch_pid = self._spawn_detached(launch_cmd)
        except FileNotFoundError:
            return ExecutionResult(
                success=False,
                execution_result="folder_open_failed",
                capability_key=capability_key,
                evidence={"path": str(target), "launch_command": launch_cmd},
                error_message="folder_open_failed",
            )
        return ExecutionResult(
            success=True,
            execution_result="folder_opened",
            capability_key=capability_key,
            evidence={
                "path": str(target),
                "launch_command": launch_cmd,
                "launch_pid": launch_pid,
                "launch_mode": "detached",
                "handler": "open_folder",
            },
        )

    def _cursor_launch_candidates(self) -> tuple[tuple[str, ...], ...]:
        try:
            from .device_capability_scanner import cursor_launch_candidates_from_profile, load_profile

            custom = cursor_launch_candidates_from_profile(load_profile())
            if custom:
                return custom
        except Exception:
            pass
        return APPLICATION_LAUNCH_CANDIDATES["cursor"]

    def _firefox_launch_candidates(self) -> tuple[tuple[str, ...], ...]:
        try:
            from .device_capability_scanner import firefox_launch_candidates_from_profile, load_profile

            custom = firefox_launch_candidates_from_profile(load_profile())
            if custom:
                return custom
        except Exception:
            pass
        return APPLICATION_LAUNCH_CANDIDATES["firefox"]

    def _handle_application_launch(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        from .device_capability_scanner import normalize_app_query_aliases

        payload = envelope.payload or {}
        explicit_app_id = str(payload.get("app_id") or "").strip().lower()
        app_id = explicit_app_id or self._resolve_app_id_from_payload(payload)
        raw_application = str(payload.get("application") or "").strip()
        raw_query = str(
            payload.get("application_query")
            or payload.get("application_name")
            or raw_application
        ).strip()
        application = (
            normalize_app_query_aliases(raw_application.lower())
            if raw_application
            else ""
        )
        if not application and raw_query:
            application = normalize_app_query_aliases(raw_query.lower())

        adapter = self._get_app_adapter()
        if app_id and adapter is not None:
            resolver = self._load_app_resolver()
            resolved = resolver.resolve(app_id)
            if resolved.status == "missing" and explicit_app_id:
                raise ValueError("application_not_found_on_device")
            entry = resolved.entry
            if resolved.status == "ambiguous" and resolved.candidates:
                entry = resolved.candidates[0]
            if entry is not None and resolved.status != "missing":
                launch = adapter.launch(app_id, entry)
                if launch.success:
                    return ExecutionResult(
                        success=True,
                        execution_result="application_launched",
                        capability_key=capability_key,
                        evidence={
                            "application": application or app_id,
                            "app_id": app_id,
                            "launch_command": launch.launch_command,
                            "launch_pid": launch.pid,
                            "launch_mode": "detached",
                            "handler": "application_launch",
                            "ide_chat_manual_required": bool(
                                payload.get("ide_chat_manual_required", False)
                            ),
                        },
                    )

        explicit_candidates = payload.get("launch_candidates")
        if isinstance(explicit_candidates, list) and explicit_candidates:
            candidates = tuple(tuple(str(part) for part in row) for row in explicit_candidates if isinstance(row, list) and row)
        elif application == "cursor":
            candidates = self._cursor_launch_candidates()
        elif application == "firefox":
            candidates = self._firefox_launch_candidates()
        elif application:
            from .device_capability_scanner import (
                load_profile,
                resolve_launch_candidates_for_application,
            )

            query = raw_query or raw_application or application
            profile = load_profile()
            candidates = resolve_launch_candidates_for_application(
                application,
                query,
                profile,
                static_candidates=APPLICATION_LAUNCH_CANDIDATES,
            )
        else:
            candidates = None
        if not candidates:
            candidates = APPLICATION_LAUNCH_CANDIDATES.get(application)
        if not candidates:
            # App not in device profile scan and no static fallback — distinct from missing grant.
            raise ValueError("application_not_found_on_device")
        errors: list[str] = []
        for cmd in candidates:
            launch_cmd = list(cmd)
            try:
                launch_pid = self._spawn_detached(launch_cmd)
            except FileNotFoundError:
                errors.append(" ".join(launch_cmd))
                continue
            return ExecutionResult(
                success=True,
                execution_result="application_launched",
                capability_key=capability_key,
                evidence={
                    "application": application,
                    "app_id": app_id or None,
                    "launch_command": launch_cmd,
                    "launch_pid": launch_pid,
                    "launch_mode": "detached",
                    "handler": "application_launch",
                    "ide_chat_manual_required": bool(
                        payload.get("ide_chat_manual_required", False)
                    ),
                },
            )
        return ExecutionResult(
            success=False,
            execution_result="application_launch_failed",
            capability_key=capability_key,
            evidence={
                "application": application,
                "app_id": app_id or None,
                "attempted_commands": errors,
                "handler": "application_launch",
            },
            error_message="application_launch_failed",
        )

    def _handle_keyboard_type_text(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        text = str(
            envelope.payload.get("text")
            or envelope.payload.get("generated_text")
            or envelope.payload.get("content")
            or ""
        ).strip()
        logger.info(
            "keyboard_type_text_input generated_text_length=%s generated_text_preview=%r text_parameter_present=%s",
            len(text),
            text[:120],
            bool(str(envelope.payload.get("text") or "").strip()),
        )
        if not text:
            raise ValueError("missing_text")
        payload = envelope.payload or {}
        app_id = self._resolve_app_id_from_payload(payload)
        target_app = str(
            payload.get("target_app")
            or payload.get("target_window")
            or "active"
        ).strip().lower()
        if app_id:
            mapped_target = self._legacy_target_app_from_app_id(app_id, fallback=target_app)
            if mapped_target:
                target_app = mapped_target
        if target_app == "browser":
            from iuve_connect_agents.desktop.ui.avatar_intent_router import sanitize_browser_keyboard_text

            source_request = str(envelope.payload.get("source_request_text") or "").strip()
            text = sanitize_browser_keyboard_text(text, source_request=source_request)
            if not text:
                raise ValueError("missing_text")
        window_name = str(envelope.payload.get("window_name") or "").strip()
        delay_sec = float(envelope.payload.get("delay_sec") or envelope.payload.get("pre_delay_sec") or 0)
        focus_tab_count = int(envelope.payload.get("focus_tab_count") or 0)
        submit_after = bool(
            envelope.payload.get("submit_after")
            or envelope.payload.get("press_enter")
            or envelope.payload.get("submit_message")
        )
        if submit_after and not bool(envelope.payload.get("publish_confirmed")):
            return ExecutionResult(
                success=False,
                execution_result="publish_confirm_required",
                error_message="permission_denied:publish_confirm_required — подтвердите отправку/публикацию",
                capability_key=capability_key,
                evidence={"stop_zone": "publish", "submit_blocked": True},
            )
        if delay_sec > 0:
            time.sleep(min(delay_sec, 10.0))

        adapter = self._get_app_adapter()
        if adapter is not None and getattr(adapter, "os_family", "linux") in {"windows", "macos"}:
            focus_app_id = app_id or self._resolve_app_id_from_payload({"target_app": target_app})
            type_result = adapter.type_text(focus_app_id or target_app, text, submit=submit_after)
            return ExecutionResult(
                success=type_result.success,
                execution_result=type_result.execution_result or "platform_automation_pending",
                error_message=type_result.error,
                capability_key=capability_key,
                evidence={
                    "text": text,
                    "target_app": target_app or "active",
                    "app_id": focus_app_id or None,
                    "handler": "keyboard_type_text",
                    "submit_after": submit_after,
                    **dict(type_result.evidence or {}),
                },
            )

        web_post_mode = bool(
            envelope.payload.get("web_post_operator")
            or envelope.payload.get("composer_mode")
            or envelope.payload.get("require_draft_verify")
        )
        if web_post_mode and target_app in {"browser", "active", ""}:
            target_app = "browser"
            from iuve_connect_agents.shared.screen_vision_gate import effective_screen_vision_granted

            from .vision_local_adapter import analyze_vision_with_fallback
            from .web_operator import focus_mytape_composer, open_web_composer, verify_typing_focus

            raw_grant = envelope.payload.get("screen_vision_granted")
            payload_grant = None if raw_grant is None else bool(raw_grant)
            screen_vision_granted = effective_screen_vision_granted(payload_grant=payload_grant)
            if avatar_desktop_vision_enabled(screen_vision_granted=screen_vision_granted):
                screenshot_path, _sha, image_bytes, capture_error = self._resolve_verify_screenshot(
                    dict(envelope.payload or {}),
                    envelope,
                )
                if not capture_error and image_bytes:
                    def _vision_analyze(**kwargs: Any) -> Any:
                        return analyze_vision_with_fallback(
                            screen_vision_granted=screen_vision_granted,
                            **kwargs,
                        )

                    focus_check = verify_typing_focus(
                        image_bytes=image_bytes,
                        vision_analyze=_vision_analyze,
                        url=str(envelope.payload.get("active_site") or ""),
                    )
                    if not focus_check.get("pass"):
                        open_result = open_web_composer(
                            payload={**dict(envelope.payload or {}), "target_app": "browser"},
                            vision_analyze=_vision_analyze,
                            image_bytes=image_bytes,
                            focus_composer_fn=focus_mytape_composer,
                        )
                        if not open_result.get("pass"):
                            return ExecutionResult(
                                success=False,
                                execution_result="keyboard_focus_failed",
                                error_message=str(open_result.get("findings") or "composer_not_open"),
                                capability_key=capability_key,
                                evidence={
                                    "handler": "keyboard_type_text",
                                    "draft_state": "composer_not_found",
                                    "wrong_control_focus": True,
                                    "web_post_status": "typed_wrong_control",
                                    "pre_type_focus_check": focus_check,
                                    "composer_recovery": open_result,
                                },
                            )

        xdotool = shutil.which("xdotool")
        focus_degraded = False
        focus_evidence: dict[str, Any] = {}
        if xdotool:
            launch_pid = int(payload.get("launch_pid") or 0)
            adapter = self._get_app_adapter()
            focus_app_id = app_id or self._resolve_app_id_from_payload({"target_app": target_app})
            if adapter is not None and focus_app_id in {
                "document_writer",
                "web_browser",
                "code_editor",
                "libreoffice",
                "browser",
                "cursor",
            }:
                focus_result = adapter.focus(
                    focus_app_id or target_app,
                    None,
                    window_hint=window_name or ("LibreOffice" if target_app == "libreoffice" else ""),
                )
                focus_ok = focus_result.success
                focus_detail = focus_result.detail
                focus_meta = focus_result.evidence
                recovery_strategy = ""
                focus_evidence = dict(focus_meta or {})
                focus_degraded = focus_result.degraded or bool(focus_meta.get("focus_degraded"))
            elif target_app == "libreoffice":
                focus_ok, focus_detail, focus_meta = self._xdotool_focus_libreoffice(
                    xdotool,
                    window_name=window_name or "LibreOffice",
                    timeout_sec=30,
                )
                recovery_strategy = ""
                focus_evidence = dict(focus_meta or {})
                focus_degraded = bool(focus_meta.get("focus_degraded"))
            else:
                focus_ok, focus_detail, focus_meta = self._xdotool_focus_target(
                    xdotool,
                    target_app=target_app,
                    window_name=window_name,
                )
                recovery_strategy = ""
                focus_evidence = dict(focus_meta or {})
                focus_degraded = bool(focus_meta.get("focus_degraded"))

            if not focus_ok and (target_app == "browser" or focus_app_id == "web_browser"):
                from .window_focus_recovery import browser_window_exists, recover_window_focus
                from .window_focus_vision_recovery import make_browser_vision_focus_callback

                screen_vision_granted = bool(envelope.payload.get("screen_vision_granted"))
                vision_callback = None
                if target_app == "browser" or focus_app_id == "web_browser":
                    vision_callback = make_browser_vision_focus_callback(
                        screen_vision_granted=screen_vision_granted,
                        window_name=window_name,
                        search_query=str(envelope.payload.get("search_query") or ""),
                    )

                focus_ok, focus_detail, recovery_strategy = recover_window_focus(
                    xdotool,
                    target_app=target_app if target_app != "active" else "browser",
                    window_name=window_name,
                    launch_pid=launch_pid,
                    vision_callback=vision_callback,
                )
                if focus_ok and str(focus_detail or "").startswith("focus_degraded:"):
                    focus_degraded = True
                    focus_evidence["focus_state"] = "FOCUS_DEGRADED"
                elif focus_ok and recovery_strategy == "vision":
                    focus_degraded = True
                    focus_evidence["focus_state"] = "FOCUS_DEGRADED"
            if not focus_ok and (target_app == "browser" or focus_app_id == "web_browser"):
                from .window_focus_recovery import browser_window_exists

                visible, window_id = browser_window_exists(xdotool, window_name=window_name)
                if visible:
                    focus_ok = True
                    focus_degraded = True
                    focus_detail = f"focus_degraded:{window_id}"
                    recovery_strategy = recovery_strategy or "browser_visible"
                    focus_evidence["focus_state"] = "FOCUS_DEGRADED"
            if not focus_ok:
                return ExecutionResult(
                    success=False,
                    execution_result="keyboard_focus_failed",
                    error_message=str(focus_detail or "xdotool_focus_failed"),
                    capability_key=capability_key,
                    evidence={"recovery_strategy": recovery_strategy or "none", **focus_evidence},
                )
            if target_app == "cursor":
                chat_focus = subprocess.run(
                    [xdotool, "key", "--clearmodifiers", "ctrl+l"],
                    capture_output=True,
                    text=True,
                    timeout=10,
                    check=False,
                )
                if chat_focus.returncode != 0:
                    return ExecutionResult(
                        success=False,
                        execution_result="keyboard_focus_failed",
                        error_message=(chat_focus.stderr or chat_focus.stdout or "cursor_chat_focus_failed").strip(),
                        capability_key=capability_key,
                    )
                time.sleep(0.5)
            elif target_app == "browser":
                if focus_tab_count > 0:
                    tab_args = [xdotool, "key", "--clearmodifiers"]
                    for _ in range(min(focus_tab_count, 8)):
                        tab_args.append("Tab")
                    tab_focus = subprocess.run(
                        tab_args,
                        capture_output=True,
                        text=True,
                        timeout=10,
                        check=False,
                    )
                    if tab_focus.returncode != 0:
                        return ExecutionResult(
                            success=False,
                            execution_result="keyboard_focus_failed",
                            error_message=(tab_focus.stderr or tab_focus.stdout or "browser_tab_focus_failed").strip(),
                            capability_key=capability_key,
                        )
                    time.sleep(0.3)
                clear_field = subprocess.run(
                    [xdotool, "key", "--clearmodifiers", "ctrl+a", "BackSpace"],
                    capture_output=True,
                    text=True,
                    timeout=10,
                    check=False,
                )
                if clear_field.returncode != 0:
                    return ExecutionResult(
                        success=False,
                        execution_result="keyboard_focus_failed",
                        error_message=(clear_field.stderr or clear_field.stdout or "browser_input_clear_failed").strip(),
                        capability_key=capability_key,
                    )
                time.sleep(0.2)
            chunk_size = 400
            chunks = [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)] or [text]
            inject_evidence: dict[str, Any] = {}
            if sys.platform.startswith("linux"):
                from .linux_text_injection import inject_text_with_xdotool

                inject_ok, inject_detail, inject_evidence = inject_text_with_xdotool(xdotool, text)
                if not inject_ok:
                    return ExecutionResult(
                        success=False,
                        execution_result="keyboard_type_failed",
                        error_message=inject_detail,
                        capability_key=capability_key,
                        evidence={"handler": "keyboard_type_text", **inject_evidence},
                    )
            else:
                for chunk in chunks:
                    typed = subprocess.run(
                        [xdotool, "type", "--delay", "12", "--", chunk],
                        capture_output=True,
                        text=True,
                        timeout=max(30, min(120, len(chunk) // 5)),
                        check=False,
                    )
                    if typed.returncode != 0:
                        return ExecutionResult(
                            success=False,
                            execution_result="keyboard_type_failed",
                            error_message=(typed.stderr or typed.stdout or "xdotool_type_failed").strip(),
                            capability_key=capability_key,
                        )
            submitted = False
            if submit_after:
                submitted_key = subprocess.run(
                    [xdotool, "key", "--clearmodifiers", "Return"],
                    capture_output=True,
                    text=True,
                    timeout=10,
                    check=False,
                )
                if submitted_key.returncode != 0:
                    return ExecutionResult(
                        success=False,
                        execution_result="keyboard_submit_failed",
                        error_message=(submitted_key.stderr or submitted_key.stdout or "xdotool_submit_failed").strip(),
                        capability_key=capability_key,
                    )
                submitted = True
                time.sleep(0.2)
            composer_mode = bool(envelope.payload.get("composer_mode") or envelope.payload.get("require_draft_verify"))
            draft_pending = composer_mode and not submit_after
            evidence = {
                "text": text,
                "target_app": target_app or "active",
                "window_name": window_name,
                "tool": "xdotool",
                "handler": "keyboard_type_text",
                "submit_after": submit_after,
                "submitted": submitted,
                "typed": True,
                **focus_evidence,
                **inject_evidence,
            }
            if focus_degraded:
                evidence["focus_state"] = "FOCUS_DEGRADED"
            if draft_pending:
                evidence.update(
                    {
                        "draft_verified": False,
                        "draft_state": "draft_inserted",
                        "composer_mode": True,
                        "web_post_status": "draft_inserted",
                    }
                )
            elif bool(envelope.payload.get("web_post_operator")):
                evidence["draft_state"] = "draft_inserted"
                evidence["web_post_status"] = "draft_inserted"
            if focus_degraded:
                typed_result = "focus_degraded"
            elif draft_pending:
                typed_result = "keyboard_typed_pending_verify"
            elif submitted:
                typed_result = "keyboard_typed_and_submitted"
            else:
                typed_result = "keyboard_typed"
            return ExecutionResult(
                success=True,
                execution_result=typed_result,
                capability_key=capability_key,
                evidence=evidence,
            )

        ydotool = shutil.which("ydotool")
        if ydotool:
            typed = subprocess.run(
                [ydotool, "type", text],
                capture_output=True,
                text=True,
                timeout=30,
                check=False,
            )
            if typed.returncode != 0:
                return ExecutionResult(
                    success=False,
                    execution_result="keyboard_type_failed",
                    error_message=(typed.stderr or typed.stdout or "ydotool_type_failed").strip(),
                    capability_key=capability_key,
                )
            submitted = False
            if submit_after:
                submitted_key = subprocess.run(
                    [ydotool, "key", "28:1", "28:0"],
                    capture_output=True,
                    text=True,
                    timeout=10,
                    check=False,
                )
                if submitted_key.returncode != 0:
                    return ExecutionResult(
                        success=False,
                        execution_result="keyboard_submit_failed",
                        error_message=(submitted_key.stderr or submitted_key.stdout or "ydotool_submit_failed").strip(),
                        capability_key=capability_key,
                    )
                submitted = True
            composer_mode = bool(envelope.payload.get("composer_mode") or envelope.payload.get("require_draft_verify"))
            draft_pending = composer_mode and not submit_after
            evidence = {
                "text": text,
                "target_app": target_app or "active",
                "window_name": window_name,
                "tool": "ydotool",
                "handler": "keyboard_type_text",
                "submit_after": submit_after,
                "submitted": submitted,
                "typed": True,
            }
            if draft_pending:
                evidence.update(
                    {
                        "draft_verified": False,
                        "draft_state": "draft_inserted",
                        "composer_mode": True,
                        "web_post_status": "draft_inserted",
                    }
                )
            elif bool(envelope.payload.get("web_post_operator")):
                evidence["draft_state"] = "draft_inserted"
                evidence["web_post_status"] = "draft_inserted"
            return ExecutionResult(
                success=True,
                execution_result=(
                    "keyboard_typed_pending_verify"
                    if draft_pending
                    else ("keyboard_typed_and_submitted" if submitted else "keyboard_typed")
                ),
                capability_key=capability_key,
                evidence=evidence,
            )

        return ExecutionResult(
            success=False,
            execution_result="keyboard_tool_missing",
            error_message="keyboard_tool_missing: install xdotool or ydotool",
            capability_key=capability_key,
        )

    def _handle_capture_screenshot(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        from .desktop_screenshot_capture import capture_screenshot as capture_desktop_screenshot

        payload = envelope.payload or {}
        plan_id = str(payload.get("plan_id") or envelope.command_id or "plan").strip()
        step_id = str(payload.get("step_id") or envelope.command_id or "capture").strip()
        region = str(payload.get("region") or "full").strip().lower()
        delay_ms = int(payload.get("delay_ms") or 0)
        try:
            artifact = capture_desktop_screenshot(
                plan_id=plan_id,
                step_id=step_id,
                region=region,
                delay_ms=delay_ms,
            )
        except Exception as exc:  # noqa: BLE001 — surface capture failure to audit
            return ExecutionResult(
                success=False,
                execution_result="screenshot_capture_failed",
                error_message=str(exc),
                capability_key=capability_key,
            )
        evidence = artifact.as_dict()
        evidence["handler"] = "capture_screenshot"
        evidence["saved_path"] = str(artifact.path)
        evidence["artifact_path"] = str(artifact.path)
        evidence["screenshot_path"] = str(artifact.path)
        if bool(payload.get("present_to_chat")):
            evidence["present_to_chat"] = True
        return ExecutionResult(
            success=True,
            execution_result="screenshot_captured",
            capability_key=capability_key,
            evidence=evidence,
        )

    def _handle_verify_web_draft(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        from iuve_connect_agents.shared.screen_vision_gate import effective_screen_vision_granted

        from .web_operator import (
            WEB_DRAFT_MAX_RECOVERY_ATTEMPTS,
            draft_verify_snippet,
            recover_composer_and_retry,
            verify_web_draft,
        )

        payload = envelope.payload or {}
        raw_grant = payload.get("screen_vision_granted")
        payload_grant = None if raw_grant is None else bool(raw_grant)
        screen_vision_granted = effective_screen_vision_granted(payload_grant=payload_grant)
        if not avatar_desktop_vision_enabled(screen_vision_granted=screen_vision_granted):
            return ExecutionResult(
                success=False,
                execution_result="vision_disabled",
                error_message="desktop vision disabled (grant inactive or AVATAR_DESKTOP_VISION_ENABLED=false)",
                capability_key=capability_key,
                evidence={"draft_state": "draft_not_created", "reason": "vision_disabled"},
            )

        expected_text = str(
            payload.get("expected_text")
            or payload.get("generated_text")
            or payload.get("text")
            or ""
        ).strip()
        if not expected_text:
            return ExecutionResult(
                success=False,
                execution_result="missing_expected_text",
                error_message="expected_text is required for web draft verification",
                capability_key=capability_key,
                evidence={"draft_state": "draft_not_created", "reason": "missing_expected_text"},
            )

        screenshot_path, screenshot_sha256, image_bytes, capture_error = self._resolve_verify_screenshot(
            payload,
            envelope,
        )
        if capture_error:
            return ExecutionResult(
                success=False,
                execution_result="screenshot_unavailable",
                error_message=capture_error,
                capability_key=capability_key,
                evidence={"draft_state": "draft_not_created", "reason": "screenshot_unavailable"},
            )

        plan_id = str(payload.get("plan_id") or envelope.command_id or "plan").strip()
        step_id = str(payload.get("step_id") or envelope.command_id or "verify_web_draft").strip()
        url = str(payload.get("active_site") or payload.get("url") or "").strip()
        try:
            min_confidence = float(payload.get("min_confidence", 0.55))
        except (TypeError, ValueError):
            min_confidence = 0.55
        min_confidence = max(0.0, min(1.0, min_confidence))

        verify_result = verify_web_draft(
            expected_text=expected_text,
            image_bytes=image_bytes,
            screen_vision_granted=screen_vision_granted,
            plan_id=plan_id,
            step_id=step_id,
            evidence_sha256=screenshot_sha256,
            url=url,
            min_confidence=min_confidence,
        )
        passed = bool(verify_result.get("pass"))
        draft_state = str(verify_result.get("draft_state") or "draft_not_created")
        recoverable = draft_state in {"typed_wrong_control", "composer_not_found", "draft_not_created"}
        enable_recovery = bool(payload.get("enable_recovery", True))

        if not passed and recoverable and enable_recovery:
            type_payload = dict(payload.get("type_payload") or {})
            type_payload.setdefault("text", expected_text)
            type_payload.setdefault("generated_text", expected_text)
            type_payload.setdefault("target_app", "browser")
            type_payload.setdefault("submit_after", False)
            type_payload.setdefault("composer_mode", True)
            type_payload.setdefault("require_draft_verify", True)

            verify_payload = dict(payload)
            verify_payload.setdefault("expected_text", expected_text)
            verify_payload.setdefault("enable_recovery", False)

            def _type_text(type_data: dict[str, Any]) -> dict[str, Any]:
                type_envelope = CommandEnvelope(
                    command_id=f"{envelope.command_id}-retry-type",
                    command_type="keyboard_type_text",
                    payload=type_data,
                )
                result = self._handle_keyboard_type_text(type_envelope, capability_key)
                return {
                    "success": bool(result.success),
                    "execution_result": result.execution_result,
                    "evidence": dict(result.evidence or {}),
                }

            def _verify(verify_data: dict[str, Any]) -> dict[str, Any]:
                verify_envelope = CommandEnvelope(
                    command_id=f"{envelope.command_id}-retry-verify",
                    command_type="verify_web_draft",
                    payload=verify_data,
                )
                result = self._handle_verify_web_draft(verify_envelope, capability_key)
                evidence = dict(result.evidence or {})
                return {
                    "pass": bool(result.success),
                    "draft_state": evidence.get("draft_state"),
                    "reason": evidence.get("reason") or evidence.get("draft_state"),
                    **evidence,
                }

            verify_result = recover_composer_and_retry(
                expected_text=expected_text,
                type_payload=type_payload,
                verify_payload=verify_payload,
                type_text_fn=_type_text,
                verify_fn=_verify,
                image_bytes=image_bytes,
                max_attempts=int(payload.get("max_recovery_attempts") or WEB_DRAFT_MAX_RECOVERY_ATTEMPTS),
            )
            passed = bool(verify_result.get("pass"))
            draft_state = str(verify_result.get("draft_state") or draft_state)

        evidence: dict[str, Any] = {
            "handler": "verify_web_draft",
            "kind": "web_draft_verify",
            "draft_state": draft_state,
            "web_post_status": draft_state,
            "reason": verify_result.get("reason") or ("" if passed else draft_state),
            "pass": passed,
            "draft_verified": passed,
            "composer_found": draft_state not in {"composer_not_found", "typed_wrong_control"},
            "expected_snippet": draft_verify_snippet(expected_text),
            "expected_text": expected_text,
            "screenshot_path": screenshot_path,
            "screenshot_sha256": screenshot_sha256,
            "verifier_result": dict(verify_result.get("verifier_result") or verify_result),
            "composer_open": verify_result.get("composer_open"),
            "cursor_in_editor": verify_result.get("cursor_in_editor"),
            "entered_text_visible": verify_result.get("entered_text_visible"),
            "text_matches_snippet": verify_result.get("text_matches_snippet"),
            "wrong_control_focus": verify_result.get("wrong_control_focus"),
            "findings": verify_result.get("findings"),
        }
        if verify_result.get("recovery_attempts"):
            evidence["recovery_attempts"] = list(verify_result.get("recovery_attempts") or [])

        return ExecutionResult(
            success=passed,
            execution_result="web_draft_verified" if passed else f"web_draft_{draft_state}",
            error_message="" if passed else str(evidence.get("findings") or draft_state),
            capability_key=capability_key,
            evidence=evidence,
        )

    def _handle_verify_published(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        """Vision check after publish click — required for honest publish success."""
        payload = envelope.payload or {}
        optional = bool(payload.get("optional", False))
        expected_text = str(payload.get("expected_text") or payload.get("text") or "").strip()
        if not expected_text:
            return ExecutionResult(
                success=False,
                execution_result="publish_verify_missing_text",
                capability_key=capability_key,
                evidence={
                    "handler": "verify_published",
                    "publish_verified": False,
                    "web_post_status": "publish_failed",
                    "optional": optional,
                },
                error_message="expected_text is required for publish verification",
            )
        draft_result = self._handle_verify_web_draft(envelope, capability_key)
        evidence = dict(draft_result.evidence or {})
        passed = bool(draft_result.success)
        # Soft signal from vision: DM/chat context means publish did not land as a public post.
        if evidence.get("is_dm_or_chat") or (
            "dm" in str(evidence.get("findings") or "").lower()
            and "post" not in str(evidence.get("findings") or "").lower()
        ):
            passed = False
            evidence["publish_context"] = "dm_or_chat"
        else:
            evidence.setdefault("publish_context", "feed_or_unknown")
        evidence.update(
            {
                "handler": "verify_published",
                "publish_verified": passed,
                "published": passed,
                "web_post_status": "publish_verified" if passed else "publish_failed",
                "draft_state": "publish_verified" if passed else "publish_failed",
            }
        )
        return ExecutionResult(
            success=passed,
            execution_result="publish_verified" if passed else "publish_failed",
            capability_key=capability_key,
            evidence=evidence,
            error_message="" if passed else str(evidence.get("findings") or "publish_failed"),
        )

    def _handle_open_web_composer(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        from iuve_connect_agents.shared.screen_vision_gate import effective_screen_vision_granted

        from .vision_local_adapter import analyze_vision_with_fallback
        from .web_operator import default_focus_composer, open_web_composer

        payload = envelope.payload or {}
        raw_grant = payload.get("screen_vision_granted")
        payload_grant = None if raw_grant is None else bool(raw_grant)
        screen_vision_granted = effective_screen_vision_granted(payload_grant=payload_grant)
        if not avatar_desktop_vision_enabled(screen_vision_granted=screen_vision_granted):
            focus_ok, detail = default_focus_composer({**payload, "target_app": "browser"})
            return ExecutionResult(
                success=focus_ok,
                execution_result="composer_focus_attempted" if focus_ok else "composer_not_found",
                capability_key=capability_key,
                evidence={
                    "handler": "open_web_composer",
                    "composer_open": focus_ok,
                    "draft_state": "composer_found" if focus_ok else "composer_not_found",
                    "findings": detail,
                    "vision_disabled_fallback": True,
                },
                error_message="" if focus_ok else detail,
            )

        screenshot_path, screenshot_sha256, image_bytes, capture_error = self._resolve_verify_screenshot(
            payload,
            envelope,
        )
        if capture_error:
            focus_ok, detail = default_focus_composer({**payload, "target_app": "browser"})
            return ExecutionResult(
                success=focus_ok,
                execution_result="composer_focus_attempted" if focus_ok else "composer_not_found",
                capability_key=capability_key,
                evidence={
                    "handler": "open_web_composer",
                    "composer_open": focus_ok,
                    "draft_state": "composer_found" if focus_ok else "composer_not_found",
                    "findings": detail or capture_error,
                    "screenshot_unavailable": True,
                },
                error_message="" if focus_ok else (capture_error or detail),
            )

        def _vision_analyze(**kwargs: Any) -> Any:
            return analyze_vision_with_fallback(
                screen_vision_granted=screen_vision_granted,
                **kwargs,
            )

        result = open_web_composer(
            payload=payload,
            vision_analyze=_vision_analyze,
            image_bytes=image_bytes,
        )
        if not bool(result.get("pass")) and int(payload.get("composer_retry_count") or 0) < 1:
            retry_payload = {**payload, "composer_retry_count": 1, "delay_sec": 2.0}
            retry_result = open_web_composer(
                payload=retry_payload,
                vision_analyze=_vision_analyze,
                image_bytes=image_bytes,
            )
            if bool(retry_result.get("pass")):
                result = retry_result
        passed = bool(result.get("pass"))
        draft_state = str(result.get("draft_state") or "composer_not_found")
        evidence = {
            **result,
            "screenshot_path": screenshot_path,
            "screenshot_sha256": screenshot_sha256,
            "web_post_status": draft_state,
        }
        return ExecutionResult(
            success=passed,
            execution_result="composer_open" if passed else draft_state,
            capability_key=capability_key,
            evidence=evidence,
            error_message="" if passed else str(result.get("findings") or draft_state),
        )

    def _handle_verify_screen_contains_text(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        from .vision_local_adapter import analyze_vision_with_fallback

        payload = envelope.payload or {}
        from iuve_connect_agents.shared.screen_vision_gate import effective_screen_vision_granted

        raw_grant = payload.get("screen_vision_granted")
        payload_grant = None if raw_grant is None else bool(raw_grant)
        screen_vision_granted = effective_screen_vision_granted(payload_grant=payload_grant)
        if not avatar_desktop_vision_enabled(screen_vision_granted=screen_vision_granted):
            return ExecutionResult(
                success=False,
                execution_result="vision_disabled",
                error_message="desktop vision disabled (grant inactive or AVATAR_DESKTOP_VISION_ENABLED=false)",
                capability_key=capability_key,
            )

        expected_text = str(payload.get("expected_text") or "").strip()
        verify_mode = str(payload.get("verify_mode") or "contains").strip().lower()
        if verify_mode == "web_research_extract":
            return self._handle_web_research_extract(envelope, capability_key, payload)

        if not expected_text:
            return ExecutionResult(
                success=False,
                execution_result="missing_expected_text",
                error_message="expected_text is required",
                capability_key=capability_key,
            )

        match_mode = str(payload.get("match_mode") or "contains").strip().lower()
        case_sensitive = bool(payload.get("case_sensitive", False))
        try:
            min_confidence = float(payload.get("min_confidence", 0.7))
        except (TypeError, ValueError):
            min_confidence = 0.7
        min_confidence = max(0.0, min(1.0, min_confidence))

        screenshot_path, screenshot_sha256, image_bytes, capture_error = self._resolve_verify_screenshot(payload, envelope)
        if capture_error:
            return ExecutionResult(
                success=False,
                execution_result="screenshot_unavailable",
                error_message=capture_error,
                capability_key=capability_key,
            )

        prompt = self._build_verify_screen_prompt(
            expected_text=expected_text,
            match_mode=match_mode,
            case_sensitive=case_sensitive,
            verify_mode=str(payload.get("verify_mode") or "contains"),
        )
        plan_id = str(payload.get("plan_id") or envelope.command_id or "plan").strip()
        step_id = str(payload.get("step_id") or envelope.command_id or "verify").strip()
        vision_result = analyze_vision_with_fallback(
            prompt=prompt,
            image_bytes=image_bytes,
            screen_vision_granted=screen_vision_granted,
            expected_text=expected_text,
            plan_id=plan_id,
            step_id=step_id,
            evidence_sha256=screenshot_sha256,
        )
        verify_mode = str(payload.get("verify_mode") or "contains").strip().lower()
        passed, verifier_result = self._evaluate_verify_result(
            vision_result=vision_result,
            expected_text=expected_text,
            match_mode=match_mode,
            case_sensitive=case_sensitive,
            min_confidence=min_confidence,
            verify_mode=verify_mode,
        )
        required_host = str(payload.get("required_url_host") or "").strip().lower().removeprefix("www.")
        if required_host:
            verifier_result["required_url_host"] = required_host

        evidence: dict[str, Any] = {
            "handler": "verify_screen_contains_text",
            "kind": "vision_verify",
            "verifier_result": verifier_result,
            "expected_text": expected_text,
            "screenshot_path": screenshot_path,
            "screenshot_sha256": screenshot_sha256,
        }
        return ExecutionResult(
            success=passed,
            execution_result="vision_verify_pass" if passed else "vision_verify_fail",
            error_message="" if passed else str(verifier_result.get("findings") or "vision_verify_fail"),
            capability_key=capability_key,
            evidence=evidence,
        )

    def _handle_describe_active_window(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        from .vision_local_adapter import analyze_vision_with_fallback
        from iuve_connect_agents.shared.screen_vision_gate import effective_screen_vision_granted

        payload = envelope.payload or {}
        delay_sec = float(payload.get("delay_sec") or 0)
        if delay_sec > 0:
            time.sleep(min(delay_sec, 10.0))

        raw_grant = payload.get("screen_vision_granted")
        payload_grant = None if raw_grant is None else bool(raw_grant)
        screen_vision_granted = effective_screen_vision_granted(payload_grant=payload_grant)
        if not avatar_desktop_vision_enabled(screen_vision_granted=screen_vision_granted):
            return ExecutionResult(
                success=False,
                execution_result="vision_disabled",
                error_message="desktop vision disabled",
                capability_key=capability_key,
            )

        screenshot_path, screenshot_sha256, image_bytes, capture_error = self._resolve_verify_screenshot(
            payload,
            envelope,
        )
        if capture_error:
            return ExecutionResult(
                success=False,
                execution_result="screenshot_unavailable",
                error_message=capture_error,
                capability_key=capability_key,
            )

        prompt = str(payload.get("prompt") or "").strip() or (
            "Определи активное окно и кратко опиши его видимое содержимое."
        )
        plan_id = str(payload.get("plan_id") or envelope.command_id or "plan").strip()
        step_id = str(payload.get("step_id") or envelope.command_id or "describe").strip()
        vision_result = analyze_vision_with_fallback(
            prompt=prompt,
            image_bytes=image_bytes,
            screen_vision_granted=screen_vision_granted,
            plan_id=plan_id,
            step_id=step_id,
            evidence_sha256=screenshot_sha256,
        )
        description = str(vision_result.findings or "").strip()
        return ExecutionResult(
            success=bool(description),
            execution_result="active_window_described" if description else "active_window_describe_failed",
            capability_key=capability_key,
            evidence={
                "handler": "describe_active_window",
                "description": description,
                "findings": description,
                "screenshot_path": screenshot_path,
                "screenshot_sha256": screenshot_sha256,
                "confidence": float(vision_result.confidence or 0.0),
            },
            error_message="" if description else "vision_describe_empty",
        )

    def _handle_list_files_in_folder(self, envelope: CommandEnvelope, capability_key: str) -> ExecutionResult:
        raw_path = str(
            envelope.payload.get("absolute_path")
            or envelope.payload.get("path")
            or ""
        ).strip()
        if not raw_path:
            raise ValueError("missing_path")
        folder = Path(raw_path).expanduser()
        if not folder.is_dir():
            raise FileNotFoundError(f"missing_folder:{folder}")

        pattern = str(envelope.payload.get("pattern") or "*").strip() or "*"
        extension = str(envelope.payload.get("extension") or "").strip().lstrip(".")
        sort_by = str(envelope.payload.get("sort_by") or "name").strip().lower()
        sort_order = str(envelope.payload.get("sort_order") or "desc").strip().lower()
        name_contains = str(envelope.payload.get("name_contains") or "").strip().lower()
        keywords_any = [
            str(item or "").strip().lower()
            for item in (envelope.payload.get("keywords_any") or [])
            if str(item or "").strip()
        ]
        min_size = int(envelope.payload.get("min_size_bytes") or 0)
        max_size = int(envelope.payload.get("max_size_bytes") or 0)
        modified_after = float(envelope.payload.get("modified_after_ts") or 0.0)
        action = str(envelope.payload.get("action") or "show_list").strip().lower()
        recursive = bool(envelope.payload.get("recursive"))
        max_entries = max(1, int(envelope.payload.get("max_entries") or 500))

        entries: list[dict[str, Any]] = []
        dir_counts: dict[str, int] = {}
        glob_paths = folder.rglob(pattern) if recursive else folder.glob(pattern)
        for path in glob_paths:
            if len(entries) >= max_entries:
                break
            if not path.is_file():
                continue
            if extension and path.suffix.lower().lstrip(".") != extension.lower():
                continue
            haystack = path.name.lower()
            if keywords_any:
                if not any(keyword in haystack for keyword in keywords_any):
                    continue
            elif name_contains and name_contains not in haystack:
                continue
            try:
                stat = path.stat()
                size = stat.st_size
                mtime = stat.st_mtime
            except OSError:
                size = 0
                mtime = 0.0
            if min_size > 0 and size < min_size:
                continue
            if max_size > 0 and size > max_size:
                continue
            if modified_after > 0 and mtime < modified_after:
                continue
            rel_parent = str(path.parent.relative_to(folder)) if path.parent != folder else "."
            dir_counts[rel_parent] = dir_counts.get(rel_parent, 0) + 1
            entries.append(
                {
                    "name": path.name,
                    "path": str(path),
                    "size_bytes": size,
                    "mtime": mtime,
                    "modified": self._format_mtime(mtime),
                    "relative_dir": rel_parent,
                }
            )

        reverse = sort_order != "asc"
        if sort_by == "size":
            entries.sort(key=lambda item: int(item.get("size_bytes") or 0), reverse=reverse)
        elif sort_by == "date":
            entries.sort(key=lambda item: float(item.get("mtime") or 0.0), reverse=reverse)
        else:
            entries.sort(key=lambda item: str(item.get("name") or "").lower(), reverse=reverse)

        empty_label = self._local_search_empty_label(extension)
        if action == "folder_analysis":
            markdown_table = self._format_folder_analysis_report(
                folder,
                entries,
                dir_counts=dir_counts,
                recursive=recursive,
            )
            file_list_text = markdown_table
        else:
            markdown_table = self._format_file_list_markdown_table(entries) if entries else ""
            file_list_text = markdown_table if markdown_table else empty_label

        return ExecutionResult(
            success=True,
            execution_result="files_listed",
            capability_key=capability_key,
            evidence={
                "handler": "list_files_in_folder",
                "path": str(folder),
                "pattern": pattern,
                "extension": extension,
                "sort_by": sort_by,
                "sort_order": sort_order,
                "match_count": len(entries),
                "files": entries,
                "entries": entries,
                "file_list_text": file_list_text,
                "markdown_table": markdown_table,
                "folder_analysis_report": markdown_table if action == "folder_analysis" else "",
                "empty_message": empty_label if not entries else "",
                "user_folder_scope": str(envelope.payload.get("user_folder_scope") or ""),
                "policy_hint": str(envelope.payload.get("policy_hint") or ""),
                "operation": "read",
                "destructive": False,
                "action": action,
                "recursive": recursive,
            },
        )

    @staticmethod
    def _format_mtime(mtime: float) -> str:
        if mtime <= 0:
            return "—"
        return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))

    @staticmethod
    def _format_size_human(size_bytes: int) -> str:
        size = max(0, int(size_bytes))
        if size < 1024:
            return f"{size} B"
        if size < 1024 * 1024:
            return f"{size // 1024} KB"
        if size < 1024 * 1024 * 1024:
            return f"{size / (1024 * 1024):.1f} MB"
        return f"{size / (1024 * 1024 * 1024):.2f} GB"

    @classmethod
    def _format_file_list_markdown_table(cls, entries: list[dict[str, Any]]) -> str:
        if not entries:
            return ""
        lines = ["| Name | Size | Path | Modified |", "| --- | --- | --- | --- |"]
        for item in entries:
            name = str(item.get("name") or "").replace("|", "\\|")
            path = str(item.get("path") or "").replace("|", "\\|")
            size = cls._format_size_human(int(item.get("size_bytes") or 0))
            modified = str(item.get("modified") or "—")
            lines.append(f"| {name} | {size} | `{path}` | {modified} |")
        return "\n".join(lines)

    @classmethod
    def _format_folder_analysis_report(
        cls,
        folder: Path,
        entries: list[dict[str, Any]],
        *,
        dir_counts: dict[str, int],
        recursive: bool,
    ) -> str:
        lines = [
            f"## Анализ содержимого: `{folder}`",
            "",
            f"- **Режим:** {'рекурсивный' if recursive else 'верхний уровень'}",
            f"- **Всего файлов:** {len(entries)}",
        ]
        if not entries:
            lines.extend(["", "Папка пуста или не содержит файлов по заданному шаблону."])
            return "\n".join(lines)

        by_ext: dict[str, int] = {}
        for item in entries:
            name = str(item.get("name") or "")
            ext = Path(name).suffix.lower().lstrip(".") or "(без расширения)"
            by_ext[ext] = by_ext.get(ext, 0) + 1

        lines.extend(["", "### По типам", ""])
        for ext, count in sorted(by_ext.items(), key=lambda pair: (-pair[1], pair[0])):
            lines.append(f"- **{ext}**: {count}")

        largest = sorted(entries, key=lambda item: int(item.get("size_bytes") or 0), reverse=True)[:5]
        lines.extend(["", "### Крупнейшие файлы", ""])
        for item in largest:
            lines.append(
                f"- `{item.get('name')}` — {cls._format_size_human(int(item.get('size_bytes') or 0))}"
            )

        if dir_counts:
            lines.extend(["", "### Структура папок (сводка)", ""])
            for rel_dir, count in sorted(dir_counts.items(), key=lambda pair: (-pair[1], pair[0]))[:12]:
                label = rel_dir if rel_dir != "." else "(корень)"
                lines.append(f"- `{label}`: {count} файл(ов)")

        lines.extend(["", "### Файлы (до 20 крупнейших)", ""])
        lines.append(cls._format_file_list_markdown_table(largest[:20]))
        return "\n".join(lines)

    @staticmethod
    def _local_search_empty_label(extension: str) -> str:
        ext = str(extension or "").strip().lower()
        if ext == "pdf":
            return "PDF-файлы не найдены"
        if ext:
            return f"Файлы *.{ext} не найдены"
        return "Файлы не найдены"

    def _handle_web_research_extract(
        self,
        envelope: CommandEnvelope,
        capability_key: str,
        payload: dict[str, Any],
    ) -> ExecutionResult:
        from .web_research_resolver import (
            _EXTRACTION_FAIL_NOTE,
            fetch_google_results_text,
            summarize_web_research_evidence,
        )

        search_query = str(payload.get("search_query") or "").strip()
        search_url = str(payload.get("search_url") or "").strip()
        try:
            min_results = max(1, int(payload.get("min_results") or 3))
        except (TypeError, ValueError):
            min_results = 3
        try:
            max_results = max(min_results, int(payload.get("max_results") or 8))
        except (TypeError, ValueError):
            max_results = 8

        fetched_results: list[dict[str, str]] = []
        if search_url:
            fetched_results, _fetch_err = fetch_google_results_text(search_url, max_results=max_results)

        collect_job_ads = bool(payload.get("collect_job_ads"))
        summary = summarize_web_research_evidence(
            search_query=search_query,
            search_url=search_url,
            fetched_results=fetched_results or None,
            max_results=max_results,
            collect_job_ads=collect_job_ads,
        )
        results = list(summary.get("results") or [])
        result_count = len(results)
        markdown = str(summary.get("markdown") or "")
        honest_note = str(summary.get("honest_note") or _EXTRACTION_FAIL_NOTE).strip()
        if result_count and result_count < min_results:
            honest_note = (
                f"Найдено {result_count} из {min_results}+ ожидаемых строк таблицы. {honest_note}"
            )
        if collect_job_ads:
            passed = result_count >= min_results
        else:
            passed = bool(markdown.strip())

        evidence: dict[str, Any] = {
            "handler": "verify_screen_contains_text",
            "kind": "web_research_extract",
            "verify_mode": "web_research_extract",
            "search_query": search_query,
            "search_url": search_url,
            "results": results,
            "markdown": markdown,
            "text": markdown,
            "result_count": result_count,
            "min_results": min_results,
            "honest_note": honest_note,
            "pass": passed,
            "collect_job_ads": collect_job_ads,
        }
        return ExecutionResult(
            success=True,
            execution_result="web_research_pass" if passed else "web_research_partial",
            error_message="" if passed else honest_note,
            capability_key=capability_key,
            evidence=evidence,
        )

    def _resolve_verify_screenshot(
        self,
        payload: dict[str, Any],
        envelope: CommandEnvelope,
    ) -> tuple[str, str, bytes, str]:
        screenshot_path = str(payload.get("screenshot_path") or payload.get("screenshot_ref") or "").strip()
        screenshot_sha256 = str(payload.get("screenshot_sha256") or "").strip()
        image_bytes = b""

        if screenshot_path:
            candidate = Path(screenshot_path)
            if candidate.is_file():
                image_bytes = candidate.read_bytes()
                return screenshot_path, screenshot_sha256, image_bytes, ""

        if not bool(payload.get("capture_if_missing", True)):
            return "", screenshot_sha256, b"", "screenshot artifact not found for verify step"

        from .desktop_screenshot_capture import capture_screenshot as capture_desktop_screenshot

        plan_id = str(payload.get("plan_id") or envelope.command_id or "plan").strip()
        step_id = str(payload.get("step_id") or envelope.command_id or "verify").strip()
        region = str(payload.get("region") or "full").strip().lower()
        delay_ms = int(payload.get("delay_ms") or 0)
        try:
            artifact = capture_desktop_screenshot(
                plan_id=plan_id,
                step_id=f"{step_id}_inline_capture",
                region=region,
                delay_ms=delay_ms,
            )
        except Exception as exc:  # noqa: BLE001
            return "", screenshot_sha256, b"", f"inline screenshot capture failed: {exc}"

        artifact_dict = artifact.as_dict()
        inline_path = str(artifact_dict.get("path") or "")
        inline_sha = str(artifact_dict.get("sha256") or "")
        if not inline_path or not Path(inline_path).is_file():
            return "", screenshot_sha256, b"", "inline screenshot capture produced no file"
        return inline_path, inline_sha, Path(inline_path).read_bytes(), ""

    @staticmethod
    def _evaluate_verify_result(
        *,
        vision_result: Any,
        expected_text: str,
        match_mode: str,
        case_sensitive: bool,
        min_confidence: float,
        verify_mode: str,
    ) -> tuple[bool, dict[str, Any]]:
        verifier_result = vision_result.as_dict()
        verifier_result["expected_text"] = expected_text
        verifier_result["match_mode"] = match_mode
        verifier_result["case_sensitive"] = case_sensitive
        effective_min = min_confidence
        if verify_mode == "chat_response":
            effective_min = min(min_confidence, 0.55)
        verifier_result["min_confidence"] = effective_min

        passed = bool(vision_result.passed) and vision_result.confidence >= effective_min
        if verify_mode == "page_loaded":
            host_hint = expected_text.lower()
            findings_lower = str(verifier_result.get("findings") or "").lower()
            passed = vision_result.passed or host_hint in findings_lower or bool(findings_lower)
            effective_min = min(effective_min, 0.45)
            if vision_result.confidence >= effective_min and findings_lower:
                passed = True
            verifier_result["page_loaded"] = passed
        if verify_mode == "url_domain":
            required_host = str(verifier_result.get("required_url_host") or expected_text or "").strip().lower()
            required_host = required_host.removeprefix("www.")
            findings_lower = str(verifier_result.get("findings") or "").lower()
            host_seen = bool(required_host) and (
                required_host in findings_lower or f"//{required_host}" in findings_lower
            )
            passed = bool(required_host) and host_seen and vision_result.confidence >= min(effective_min, 0.45)
            verifier_result["required_url_host"] = required_host
            verifier_result["url_domain_verified"] = host_seen
        if not passed and verify_mode == "chat_response":
            passed = CommandExecutor._chat_response_partial_match(
                expected_text=expected_text,
                findings=str(verifier_result.get("findings") or ""),
                case_sensitive=case_sensitive,
                vision_passed=bool(vision_result.passed),
                confidence=float(vision_result.confidence or 0.0),
                effective_min=effective_min,
            )
            if passed:
                verifier_result["partial_match"] = True

        verifier_result["pass"] = passed
        return passed, verifier_result

    @staticmethod
    def _chat_response_partial_match(
        *,
        expected_text: str,
        findings: str,
        case_sensitive: bool,
        vision_passed: bool,
        confidence: float,
        effective_min: float,
    ) -> bool:
        phrase = str(expected_text or "").strip()
        haystack = str(findings or "").strip()
        if not phrase or not haystack:
            return False
        if vision_passed and confidence >= max(0.35, effective_min - 0.2):
            return True
        if case_sensitive:
            return phrase in haystack
        return phrase.lower() in haystack.lower()

    @staticmethod
    def _build_verify_screen_prompt(
        *,
        expected_text: str,
        match_mode: str,
        case_sensitive: bool,
        verify_mode: str = "contains",
    ) -> str:
        mode_hint = "exact match" if match_mode == "exact" else "contains"
        case_hint = "case-sensitive" if case_sensitive else "case-insensitive"
        if str(verify_mode or "").strip().lower() == "page_loaded":
            return (
                f"Does the desktop screenshot show a fully loaded browser page related to "
                f"{expected_text!r}? Accept browser chrome, title bar, or visible page body. "
                f"Partial load indicators are acceptable if main content is visible."
            )
        if str(verify_mode or "").strip().lower() == "url_domain":
            host = str(expected_text or "").strip().lower().removeprefix("www.")
            return (
                f"Does the browser address bar or visible page show the domain {host!r}? "
                f"PASS only if {host!r} is clearly shown — not google.com or another search page."
            )
        if str(verify_mode or "").strip().lower() == "chat_response":
            return (
                f"Does the desktop screenshot show either (a) the user message containing "
                f"{expected_text!r} or (b) a visible assistant/chat response bubble after that message "
                f"({case_hint})? Focus on browser chat UI content; partial phrase match is acceptable."
            )
        return (
            f"Does the desktop screenshot show text that {mode_hint} the phrase {expected_text!r} "
            f"({case_hint})? Focus on visible UI content such as chat responses or dialogs."
        )

    @staticmethod
    def _xdotool_window_name(xdotool: str, window_id: str) -> str:
        result = subprocess.run(
            [xdotool, "getwindowname", window_id],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
        if result.returncode != 0:
            return ""
        return str(result.stdout or "").strip()

    @staticmethod
    def _xdotool_list_class_windows(xdotool: str, class_name: str) -> list[str]:
        result = subprocess.run(
            [xdotool, "search", "--class", class_name],
            capture_output=True,
            text=True,
            timeout=15,
            check=False,
        )
        if result.returncode != 0:
            return []
        return [wid for wid in str(result.stdout or "").splitlines() if wid.strip()]

    @classmethod
    def _xdotool_activate_browser_window(
        cls,
        xdotool: str,
        *,
        window_name: str = "",
    ) -> tuple[bool, str, dict[str, Any]]:
        from iuve_connect_agents.desktop.platform_adapters.linux.app_adapter import LinuxAppAdapter

        return LinuxAppAdapter._xdotool_activate_browser_window(xdotool, window_name=window_name)

    @staticmethod
    def _xdotool_focus_libreoffice(
        xdotool: str,
        *,
        window_name: str,
        timeout_sec: int = 30,
    ) -> tuple[bool, str, dict[str, Any]]:
        from iuve_connect_agents.desktop.platform_adapters.linux.app_adapter import LinuxAppAdapter

        return LinuxAppAdapter._xdotool_focus_libreoffice(
            xdotool,
            window_name=window_name,
            timeout_sec=timeout_sec,
        )

    @staticmethod
    def _xdotool_focus_target(
        xdotool: str,
        *,
        target_app: str,
        window_name: str,
    ) -> tuple[bool, str, dict[str, Any]]:
        from iuve_connect_agents.desktop.platform_adapters.linux.app_adapter import LinuxAppAdapter

        adapter = LinuxAppAdapter()
        if target_app == "cursor":
            result = adapter.focus("code_editor", None, window_hint=window_name)
            return result.success, result.detail, result.evidence
        if target_app == "browser":
            result = adapter.focus("web_browser", None, window_hint=window_name)
            return result.success, result.detail, result.evidence
        if target_app == "libreoffice":
            result = adapter.focus("document_writer", None, window_hint=window_name or "LibreOffice")
            return result.success, result.detail, result.evidence
        result = adapter.focus(target_app, None, window_hint=window_name)
        return result.success, result.detail, result.evidence

    @staticmethod
    def _rules_generate_content(content_type: str, *, topic: str, research_context: str = "") -> str:
        ctype = str(content_type or "document").strip().lower()
        subject = str(topic or "тема").strip() or "тема"
        context_block = ""
        if research_context.strip():
            context_block = f"\n\n## Источники (web research)\n\n{research_context.strip()[:4000]}\n"
        if ctype == "html_landing":
            title = subject.title()
            return (
                "<!DOCTYPE html>\n<html lang=\"ru\"><head><meta charset=\"utf-8\">"
                f"<title>{title}</title></head><body><h1>{title}</h1>"
                f"<p>Лендинг о {subject} — IuVe Avatar.</p></body></html>\n"
            )
        if ctype == "social_post":
            return f"{subject}\n\nПост о {subject}, подготовленный IuVe Avatar."
        if ctype == "article":
            return (
                f"{subject}\n\n"
                f"Введение\n\n"
                f"Статья о {subject}, подготовленная IuVe Avatar для локального документа.\n\n"
                f"Основная часть\n\n"
                f"Ключевые аспекты темы «{subject}» и практические выводы для читателя.\n\n"
                f"Заключение\n\n"
                f"Итоговые мысли по теме «{subject}».\n"
            )
        if ctype == "email":
            return f"Тема: {subject}\n\nЗдравствуйте,\n\nПисьмо о {subject}.\n{context_block}"
        return f"# {subject}\n\nДокумент о {subject}, подготовленный IuVe Avatar на основе web research.{context_block}\n"

    @staticmethod
    def _rules_summarize(text: str, *, goal_summary: str) -> str:
        cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
        goal = str(goal_summary or "веб-страница").strip()
        locality_hint = ""
        lower = cleaned.lower()
        if "primăria" in lower or "primaria" in lower or "stăuceni" in lower or "stauceni" in lower:
            locality_hint = (
                "Сайт относится к муниципальной администрации Primăria Stăuceni (stauceni.md). "
            )
        excerpt = cleaned[:1200]
        return (
            f"{locality_hint}Краткое содержание ({goal}):\n\n"
            f"{excerpt}"
            f"{'…' if len(cleaned) > len(excerpt) else ''}"
        )

    @staticmethod
    def _spawn_detached(cmd: list[str]) -> int:
        """Launch a GUI/external command without waiting for exit."""
        if not cmd:
            raise ValueError("empty_launch_command")
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
            close_fds=True,
        )
        return int(proc.pid)

    @staticmethod
    def _editor_launch_command(editor: str, target: Path) -> list[str]:
        if editor == "libreoffice":
            return ["libreoffice", "--writer", str(target)]
        if editor == "vscode":
            return ["code", "--goto", str(target)]
        if editor == "gedit":
            return ["gedit", str(target)]
        return ["xdg-open", str(target)]

    @staticmethod
    def _detect_os_family() -> str:
        os_family = platform.system().lower()
        if os_family == "darwin":
            return "macos"
        return os_family

    def _get_app_adapter(self):
        from iuve_connect_agents.desktop.platform_adapters.adapter_registry import get_app_adapter

        return get_app_adapter(self._detect_os_family())

    def _get_linux_adapter(self):
        """Backward-compatible alias for Phase 3b call sites."""
        return self._get_app_adapter()

    def _load_app_resolver(self):
        from iuve_connect_agents.shared.app_resolver import AppResolver

        from .device_capability_scanner import load_profile, map_inventory_to_installed_apps

        profile = load_profile() or {}
        installed_apps = profile.get("installed_apps")
        if not isinstance(installed_apps, list):
            installed_apps = []
        if not installed_apps:
            apps = profile.get("applications")
            if isinstance(apps, list) and apps:
                installed_apps = map_inventory_to_installed_apps(apps, os_family=self._detect_os_family())
        return AppResolver(installed_apps=installed_apps, os_family=self._detect_os_family())

    @staticmethod
    def _resolve_app_id_from_payload(payload: dict[str, Any]) -> str:
        app_id = str(payload.get("app_id") or "").strip().lower()
        if app_id:
            return app_id
        from iuve_connect_agents.shared.app_resolver import legacy_slug_to_app_id

        for key in ("application", "target_app", "editor"):
            legacy = str(payload.get(key) or "").strip()
            if not legacy:
                continue
            mapped = legacy_slug_to_app_id(legacy)
            if mapped:
                return mapped
        return ""

    @staticmethod
    def _legacy_target_app_from_app_id(app_id: str, fallback: str = "") -> str:
        mapping = {
            "web_browser": "browser",
            "document_writer": "libreoffice",
            "code_editor": "cursor",
        }
        return mapping.get(str(app_id or "").strip().lower(), fallback)

    def _resolve_logs_path(self) -> Path:
        if self.logs_path_provider is not None:
            raw = str(self.logs_path_provider() or "").strip()
            if raw:
                return Path(raw).expanduser()
        return Path.home() / ".config" / "iuve-connect-agent" / "desktop-agent.log"

    @staticmethod
    def _tail_file(path: Path, *, max_lines: int) -> list[str]:
        with path.open("r", encoding="utf-8", errors="replace") as handle:
            return handle.readlines()[-max_lines:]

    def _enforce_routing_write_path(
        self,
        envelope: CommandEnvelope,
        target: Path,
    ) -> ExecutionResult | None:
        from .capability_resolver import validate_write_path_for_policy, resolve_workspace_routing_mode

        payload = envelope.payload or {}
        routing_mode = str(payload.get("routing_mode") or "").strip().lower()
        if routing_mode not in {"safe", "balanced", "trusted"}:
            routing_mode = resolve_workspace_routing_mode(
                trust_state=str(payload.get("trust_state") or "trusted"),
                config_value=str(payload.get("routing_mode") or ""),
            )
        file_access_granted = bool(payload.get("file_access_granted"))
        scope_paths = envelope.scope.get("workspace_paths")
        if not isinstance(scope_paths, list):
            scope_paths = None
        allowed, hint = validate_write_path_for_policy(
            target,
            routing_mode=routing_mode,  # type: ignore[arg-type]
            trust_state=str(payload.get("trust_state") or "trusted"),
            file_access_granted=file_access_granted,
            scope_workspace_paths=scope_paths,
        )
        if allowed:
            return None
        return ExecutionResult(
            success=False,
            execution_result="path_outside_routing_scope",
            error_message=f"permission_denied:path_outside_routing_scope — {hint}",
            evidence={"policy_hint": hint, "routing_mode": routing_mode},
        )

    def _resolve_workspace_target(self, envelope: CommandEnvelope) -> Path:
        scope = envelope.scope
        workspace_paths = scope.get("workspace_paths")
        if not isinstance(workspace_paths, list) or not workspace_paths:
            raise PermissionError("permission_denied:missing_scope")

        roots = [os.path.realpath(str(root)) for root in workspace_paths if str(root).strip()]
        if not roots:
            raise PermissionError("permission_denied:missing_scope")

        relative_path = str(envelope.payload.get("relative_path") or "").strip()
        absolute_path = str(envelope.payload.get("absolute_path") or envelope.payload.get("path") or "").strip()

        if ".." in Path(relative_path).parts if relative_path else False:
            raise PermissionError("permission_denied:path_traversal")

        if absolute_path:
            candidate = os.path.realpath(absolute_path)
        elif relative_path:
            candidate = os.path.realpath(os.path.join(roots[0], relative_path))
        else:
            raise ValueError("missing_path")

        for root in roots:
            root_prefix = root if root.endswith(os.sep) else root + os.sep
            if candidate == root or candidate.startswith(root_prefix):
                return Path(candidate)

        raise PermissionError("permission_denied:path_outside_scope")
