"""Local device capability profile scan and store (E1 MVP, 0.1.25.0)."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
import difflib
import json
import os
import platform
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
from typing import Any, Callable

from .device_inventory_filter import (
    browsers_from_apps as _browsers_from_apps_filtered,
    canonical_desktop_stem,
    editors_from_apps as _editors_from_apps_filtered,
    filter_meaningful_inventory_apps as _filter_meaningful_inventory_apps_shared,
    inject_priority_known_apps,
    is_junk_inventory_app,
    matches_browser_app,
    matches_editor_app,
    matches_messenger_app,
    matches_office_app,
)

PROFILE_SCHEMA_VERSION = "0.2.2.23"
DEFAULT_STATE_DIR = Path.home() / ".local/share/iuve-connect"
PROFILE_FILENAME = "device_capability_profile.json"
_DEVICE_SCAN_LOG_PATH = Path.home() / ".config" / "iuve-connect-agent" / "desktop-agent.log"
STALE_AFTER_SEC = 24 * 3600
SCAN_TIMEOUT_SEC = 45


def _log_device_scan(event: str, **fields: object) -> None:
    """Append scan lifecycle events to desktop-agent.log (shared with systemd agent)."""
    try:
        from ..desktop_event_logger import DesktopAgentEventLogger

        DesktopAgentEventLogger(_DEVICE_SCAN_LOG_PATH).log(event, fields)
    except Exception:
        pass
TOP_APPS_N = 20
# Full .desktop inventory feeds discovered_apps_allowlist (dynamic launch allowlist).
# Security: auto-allowlist does NOT bypass application_control grant — PolicyEngine upstream.

_PRIORITY_APP_KEYWORDS = (
    "cursor",
    "code",
    "vscode",
    "visual studio code",
    "libreoffice",
    "onlyoffice",
    "firefox",
    "chrome",
    "chromium",
    "brave",
    "terminal",
    "konsole",
    "gnome-terminal",
    "thunderbird",
    "outlook",
    "telegram",
    "slack",
    "discord",
    "whatsapp",
    "signal",
    "zoom",
    "teams",
    "viber",
    "gedit",
    "kate",
)

_AI_PROVIDER_APP_PATTERNS = (
    "claude",
    "anthropic",
    "grok",
    "x.ai",
    "chatgpt",
    "openai",
    "whatsapp web",
    "gemini",
    "copilot",
    "perplexity",
    "deepseek",
    "midjourney",
)

_LIBREOFFICE_TEMPLATE_NAME_RE = re.compile(
    r"^new\s+(document|drawing|formula|presentation|spreadsheet)\b",
    re.IGNORECASE,
)
_JUNK_INVENTORY_ACTION_PREFIXES = (
    "quit ",
    "open profile manager",
    "log out",
    "logout ",
)
_JUNK_INVENTORY_STANDALONE_NAMES = frozenset(
    {
        "math",
        "new document",
        "new drawing",
        "new formula",
        "new presentation",
        "new spreadsheet",
    }
)
_MESSENGER_APP_KEYWORDS = (
    "telegram",
    "viber",
    "slack",
    "discord",
    "signal",
    "whatsapp",
    "zoom",
    "teams",
    "skype",
)
_OFFICE_APP_KEYWORDS = (
    "libreoffice",
    "onlyoffice",
    "openoffice",
    "wps office",
    "abiword",
    "gnumeric",
)
_INVENTORY_DISPLAY_CAP = 20

_EDITOR_CATEGORY_TOKENS = frozenset(
    {
        "development",
        "ide",
        "texteditor",
        "editor",
    }
)
_BROWSER_CATEGORY_TOKENS = frozenset({"webbrowser"})

_KNOWN_BROWSER_DESKTOP_STEMS = frozenset(
    {
        "firefox",
        "firefox-esr",
        "org.mozilla.firefox",
        "google-chrome",
        "google-chrome-stable",
        "com.google.chrome",
        "chromium",
        "chromium-browser",
        "brave-browser",
        "microsoft-edge",
        "microsoft-edge-dev",
        "opera",
        "vivaldi",
        "vivaldi-stable",
    }
)

_KNOWN_EDITOR_DESKTOP_STEMS = frozenset(
    {
        "cursor",
        "code",
        "code-oss",
        "codium",
        "vscodium",
        "visual-studio-code",
        "com.visualstudio.code",
    }
)

_NOISE_NAME_SUBSTRINGS = (
    "new incognito",
    "new private window",
    "new empty window",
    "new window",
    "preferences",
    "settings",
    "bluetooth",
    "print preview",
    "about chrome",
    "about firefox",
    "manage extensions",
    "task manager",
    "clear browsing data",
    "restore session",
    "unsafe browser",
    "profile manager",
)

_NOISE_ONLY_CATEGORIES = frozenset(
    {
        "Settings",
        "X-GNOME-Settings-Panel",
        "X-KDE-settings",
        "X-GNOME-NetworkSettings",
        "X-GNOME-Bluetooth",
    }
)


def default_profile_path() -> Path:
    return DEFAULT_STATE_DIR / PROFILE_FILENAME


def _utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _parse_scanned_at(profile: dict[str, Any]) -> datetime | None:
    raw = str(profile.get("scanned_at") or "").strip()
    if not raw:
        return None
    try:
        return datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError:
        return None


def is_profile_stale(profile: dict[str, Any] | None, *, max_age_sec: int = STALE_AFTER_SEC) -> bool:
    if not profile:
        return True
    scanned = _parse_scanned_at(profile)
    if scanned is None:
        return True
    age = (datetime.now(timezone.utc) - scanned.astimezone(timezone.utc)).total_seconds()
    return age > max_age_sec


def load_profile(path: Path | None = None) -> dict[str, Any] | None:
    target = path or default_profile_path()
    if not target.exists():
        return None
    try:
        data = json.loads(target.read_text(encoding="utf-8"))
        if isinstance(data, dict):
            return data
    except Exception:
        pass
    return None


def save_profile(profile: dict[str, Any], path: Path | None = None) -> Path:
    target = path or default_profile_path()
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(
        json.dumps(profile, ensure_ascii=False, indent=2, sort_keys=True),
        encoding="utf-8",
    )
    try:
        os.chmod(target, 0o600)
    except OSError:
        pass
    return target


def _read_cpu_info() -> dict[str, Any]:
    model = "unknown"
    cores = 0
    try:
        with open("/proc/cpuinfo", "r", encoding="utf-8") as fh:
            for line in fh:
                if line.lower().startswith("model name"):
                    model = line.split(":", 1)[1].strip() or model
                if line.lower().startswith("processor"):
                    cores += 1
    except OSError:
        pass
    if cores <= 0:
        cores = os.cpu_count() or 1
    return {"model": model, "cores": cores}


def _read_memory() -> dict[str, Any]:
    totals: dict[str, int] = {}
    try:
        with open("/proc/meminfo", "r", encoding="utf-8") as fh:
            for line in fh:
                if ":" not in line:
                    continue
                key, rest = line.split(":", 1)
                parts = rest.strip().split()
                if parts:
                    totals[key] = int(parts[0])
    except OSError:
        pass
    total_mb = int(round((totals.get("MemTotal") or 0) / 1024))
    available_mb = int(round((totals.get("MemAvailable") or 0) / 1024))
    return {
        "total_mb": total_mb,
        "available_mb": available_mb,
        "used_mb": max(0, total_mb - available_mb),
    }


def _read_disk_free_gb() -> float:
    try:
        usage = shutil.disk_usage(Path.home())
        return round(usage.free / (1024 ** 3), 1)
    except OSError:
        return 0.0


def _detect_desktop_environment() -> str:
    for key in ("XDG_CURRENT_DESKTOP", "DESKTOP_SESSION"):
        value = str(os.environ.get(key) or "").strip()
        if value:
            return value.split(":")[0].strip() or value
    return "unknown"


def _detect_display_server() -> str:
    if str(os.environ.get("WAYLAND_DISPLAY") or "").strip():
        return "Wayland"
    if str(os.environ.get("DISPLAY") or "").strip():
        return "X11"
    return "unknown"


def _detect_tools() -> dict[str, bool]:
    return {
        "xdotool": bool(shutil.which("xdotool")),
        "ydotool": bool(shutil.which("ydotool")),
        "gio": bool(shutil.which("gio")),
    }


def _workspace_path() -> str:
    workspace = Path.home() / "IuVeWorkspace"
    try:
        workspace.mkdir(parents=True, exist_ok=True)
    except OSError:
        pass
    return str(workspace)


def _desktop_search_dirs() -> list[Path]:
    """Return .desktop search paths in priority order (user/local before system)."""
    dirs: list[Path] = []
    seen: set[str] = set()

    def _add(path: Path) -> None:
        resolved = str(path.expanduser())
        if resolved in seen:
            return
        seen.add(resolved)
        dirs.append(path)

    home = Path.home()
    _add(home / ".local/share/applications")
    _add(home / ".local/share/flatpak/exports/share/applications")

    xdg_data_home = str(os.environ.get("XDG_DATA_HOME") or "").strip()
    if xdg_data_home:
        data_home = Path(xdg_data_home).expanduser()
        _add(data_home / "applications")
        _add(data_home / "flatpak/exports/share/applications")

    xdg_data_dirs = str(
        os.environ.get("XDG_DATA_DIRS") or "/usr/local/share/:/usr/share/"
    ).split(":")
    for raw_dir in xdg_data_dirs:
        raw_dir = raw_dir.strip()
        if not raw_dir:
            continue
        _add(Path(raw_dir) / "applications")

    _add(Path("/var/lib/flatpak/exports/share/applications"))
    _add(Path("/var/lib/snapd/desktop/applications"))
    _add(Path("/usr/local/share/applications"))
    _add(Path("/usr/share/applications"))
    return dirs


def _parse_desktop_ini_sections(text: str) -> dict[str, dict[str, str]]:
    """Parse .desktop INI sections; ignore [Desktop Action *] blocks."""
    sections: dict[str, dict[str, str]] = {}
    current: str | None = None
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("[") and line.endswith("]"):
            current = line[1:-1].strip()
            sections.setdefault(current, {})
            continue
        if current is None or "=" not in line:
            continue
        key, value = line.split("=", 1)
        sections[current][key.strip()] = value.strip()
    return sections


def _desktop_entry_fields_for_path(path: Path, text: str) -> dict[str, str] | None:
    sections = _parse_desktop_ini_sections(text)
    fields = sections.get("Desktop Entry")
    if not fields:
        # Legacy single-section files without explicit header.
        fields = {}
        for line in text.splitlines():
            if line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            fields[key.strip()] = value.strip()
    if not fields:
        return None
    if fields.get("NoDisplay", "").lower() == "true":
        return None
    if fields.get("Hidden", "").lower() == "true":
        return None
    not_show_in = {item.strip().lower() for item in fields.get("NotShowIn", "").split(";") if item.strip()}
    if not_show_in:
        desktop_env = _detect_desktop_environment().lower()
        env_tokens = {token.strip().lower() for token in re.split(r"[:;\s]+", desktop_env) if token.strip()}
        if not_show_in & env_tokens:
            return None
    if fields.get("Type", "").lower() not in {"", "application"}:
        return None
    return fields


def _parse_desktop_file(path: Path) -> dict[str, str] | None:
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return None
    fields = _desktop_entry_fields_for_path(path, text)
    if not fields:
        return None
    name = fields.get("Name") or path.stem
    exec_line = fields.get("Exec") or ""
    if not exec_line:
        return None
    exec_cmd = re.sub(r"%[fFuUdDnNickvm]", "", exec_line).strip()
    categories = fields.get("Categories") or ""
    if _is_noise_desktop_entry(name=name, exec_cmd=exec_cmd, categories=categories):
        return None
    desktop_id = path.name
    canonical = canonical_desktop_stem(
        {"desktop_file": str(path), "desktop_id": desktop_id, "name": name, "exec": exec_cmd}
    )
    parsed: dict[str, str] = {
        "name": name,
        "exec": exec_cmd,
        "desktop_file": str(path),
        "desktop_id": desktop_id,
        "categories": categories,
    }
    if canonical:
        parsed["canonical_id"] = canonical
    return parsed


def _is_noise_desktop_entry(*, name: str, exec_cmd: str, categories: str) -> bool:
    lowered_name = str(name or "").strip().lower()
    lowered_exec = str(exec_cmd or "").strip().lower()
    if not lowered_name:
        return True
    if any(noise in lowered_name for noise in _NOISE_NAME_SUBSTRINGS):
        return True
    if lowered_name.startswith("new ") and any(
        token in lowered_name for token in ("window", "incognito", "private", "tab")
    ):
        return True
    if _LIBREOFFICE_TEMPLATE_NAME_RE.match(lowered_name):
        return True
    if lowered_name in _JUNK_INVENTORY_STANDALONE_NAMES:
        return True
    if any(lowered_name.startswith(prefix) for prefix in _JUNK_INVENTORY_ACTION_PREFIXES):
        return True
    category_tokens = {item.strip() for item in str(categories or "").split(";") if item.strip()}
    if category_tokens and category_tokens.issubset(_NOISE_ONLY_CATEGORIES):
        return True
    if "chrome" in lowered_exec and any(
        token in lowered_name for token in ("incognito", "preferences", "settings", "new ")
    ):
        return True
    return False


def _app_priority_score(app: dict[str, str]) -> int:
    blob = f"{app.get('name', '')} {app.get('exec', '')} {app.get('categories', '')}".lower()
    score = 0
    for index, keyword in enumerate(_PRIORITY_APP_KEYWORDS):
        if keyword in blob:
            score += (len(_PRIORITY_APP_KEYWORDS) - index) * 10
    return score


def _collect_desktop_apps(
    *,
    limit: int | None = None,
    deadline: float | None = None,
) -> tuple[list[dict[str, str]], bool]:
    """Collect .desktop apps; stop at deadline. Returns (apps, timed_out_partial)."""
    seen: set[str] = set()
    apps: list[dict[str, str]] = []
    timed_out = False
    for directory in _desktop_search_dirs():
        if deadline is not None and time.monotonic() >= deadline:
            timed_out = True
            break
        if not directory.is_dir():
            continue
        try:
            entries = sorted(directory.glob("*.desktop"))
        except OSError:
            continue
        for desktop_path in entries:
            if deadline is not None and time.monotonic() >= deadline:
                timed_out = True
                break
            parsed = _parse_desktop_file(desktop_path)
            if parsed is None:
                continue
            desktop_id = str(parsed.get("desktop_id") or desktop_path.name).lower()
            key = desktop_id or f"{parsed['name'].lower()}|{parsed.get('exec', '').lower()}"
            if key in seen:
                continue
            seen.add(key)
            apps.append(parsed)
        if timed_out:
            break
    if not timed_out:
        apps = inject_priority_known_apps(apps, _desktop_search_dirs(), _parse_desktop_file)
    apps.sort(key=lambda item: (-_app_priority_score(item), item["name"].lower()))
    if limit is not None:
        return apps[:limit], timed_out
    return apps, timed_out


def _scan_desktop_apps(*, top_n: int = TOP_APPS_N) -> list[dict[str, str]]:
    apps, _ = _collect_desktop_apps(limit=top_n)
    return apps


def _scan_all_desktop_apps(*, deadline: float | None = None) -> tuple[list[dict[str, str]], bool]:
    """Full inventory for dynamic launch allowlist (platform-specific)."""
    family = _detect_os_family()
    if family == "windows":
        return _scan_windows_installed_apps(deadline=deadline)
    if family == "macos":
        return _scan_macos_installed_apps(deadline=deadline)
    return _collect_desktop_apps(deadline=deadline)


def _allowlist_entry_key(entry: dict[str, Any]) -> str:
    desktop_file = str(entry.get("desktop_file") or "").strip()
    if desktop_file:
        return desktop_file.lower()
    name = str(entry.get("name") or "").strip().lower()
    exec_line = str(entry.get("exec") or "").strip().lower()
    return f"{name}|{exec_line}"


def build_discovered_apps_allowlist(apps: list[dict[str, str]]) -> list[dict[str, Any]]:
    """Build dynamic launch allowlist entries from scanned .desktop apps.

    Security: each entry references a .desktop file — no arbitrary binary launch paths.
    Grant application_control is still required (PolicyEngine); this only widens which
    installed apps may be launched once the user opts in.
    """
    entries: list[dict[str, Any]] = []
    for app in apps:
        desktop_file = str(app.get("desktop_file") or "").strip()
        if not desktop_file:
            continue
        name = str(app.get("name") or Path(desktop_file).stem)
        canonical = str(app.get("canonical_id") or canonical_desktop_stem(app) or "")
        entry: dict[str, Any] = {
                "key": _allowlist_entry_key(app),
                "slug": _slugify_app_name(name),
                "name": name,
                "desktop_file": desktop_file,
                "desktop_id": str(app.get("desktop_id") or Path(desktop_file).name),
                "exec": str(app.get("exec") or "").strip(),
                "categories": str(app.get("categories") or "").strip(),
            }
        if canonical:
            entry["canonical_id"] = canonical
        entries.append(entry)
    return entries


def merge_discovered_allowlist(
    previous: list[dict[str, Any]] | None,
    scanned: list[dict[str, Any]] | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    """Merge scanned allowlist with prior snapshot; return (merged, newly_discovered)."""
    prev_by_key: dict[str, dict[str, Any]] = {}
    for entry in previous or []:
        if not isinstance(entry, dict):
            continue
        key = _allowlist_entry_key(entry)
        if key:
            prev_by_key[key] = dict(entry)

    merged: list[dict[str, Any]] = []
    newly_discovered: list[dict[str, Any]] = []
    for entry in scanned or []:
        if not isinstance(entry, dict):
            continue
        key = _allowlist_entry_key(entry)
        if not key:
            continue
        if key in prev_by_key:
            kept = dict(prev_by_key[key])
            for field in ("name", "exec", "desktop_file", "slug", "key"):
                if field in entry:
                    kept[field] = entry[field]
            merged.append(kept)
        else:
            fresh = dict(entry)
            fresh.setdefault("discovered_at", _utc_now_iso())
            merged.append(fresh)
            newly_discovered.append(fresh)
    return merged, newly_discovered


def refresh_profile_light(profile: dict[str, Any] | None) -> dict[str, Any]:
    """Heartbeat light diff: refresh RAM/disk only, keep cached app list."""
    base = dict(profile) if isinstance(profile, dict) else {}
    base.update(
        {
            "schema_version": PROFILE_SCHEMA_VERSION,
            "scanned_at": _utc_now_iso(),
            "scan_reason": "heartbeat_light",
            "memory": _read_memory(),
            "disk": {"free_gb": _read_disk_free_gb()},
        }
    )
    if "applications" not in base:
        base["applications"] = _scan_desktop_apps()
        base["applications_count"] = len(base["applications"])
        base["cursor_found"] = any(
            "cursor" in app.get("name", "").lower() or "cursor" in app.get("exec", "").lower()
            for app in base["applications"]
        )
    else:
        apps = base.get("applications")
        base["applications_count"] = len(apps) if isinstance(apps, list) else int(base.get("applications_count") or 0)
    scan_apps = _profile_scan_apps(base)
    if scan_apps:
        meaningful = _filter_meaningful_inventory_apps(scan_apps)
        inventory_editors = _editors_from_apps(meaningful)
        inventory_browsers = _browsers_from_apps(meaningful)
        base["inventory_editors"] = [{"name": app.get("name", "?")} for app in inventory_editors]
        base["inventory_browsers"] = [{"name": app.get("name", "?")} for app in inventory_browsers]
        base["browsers_count"] = len(inventory_browsers)
    apps = base.get("applications")
    if isinstance(apps, list):
        base["installed_apps"] = map_inventory_to_installed_apps(apps, os_family=_detect_os_family())
    return base


def scan_device_capability_profile(
    *,
    scan_reason: str = "",
    deadline: float | None = None,
) -> dict[str, Any]:
    """Collect local device capability profile (no secrets).

    When deadline is set, app inventory collection stops at the deadline and
    returns partial results with scan_partial=True.
    """
    all_apps, timed_out = _scan_all_desktop_apps(deadline=deadline)
    meaningful_apps = _filter_meaningful_inventory_apps(all_apps)
    display_apps = meaningful_apps[:TOP_APPS_N]
    allowlist = build_discovered_apps_allowlist(all_apps)
    inventory_editors = _editors_from_apps(meaningful_apps)
    inventory_browsers = _browsers_from_apps(meaningful_apps)
    profile: dict[str, Any] = {
        "schema_version": PROFILE_SCHEMA_VERSION,
        "scanned_at": _utc_now_iso(),
        "scan_reason": str(scan_reason or "scheduled"),
        "cpu": _read_cpu_info(),
        "memory": _read_memory(),
        "disk": {"free_gb": _read_disk_free_gb()},
        "os": {
            "name": platform.system() or "unknown",
            "version": platform.release() or "unknown",
            "platform": platform.platform(terse=True),
        },
        "desktop": {
            "environment": _detect_desktop_environment(),
            "display_server": _detect_display_server(),
        },
        "workspace": {"path": _workspace_path()},
        "tools": _detect_tools(),
        "applications": display_apps,
        "applications_count": len(display_apps),
        "allowlist_count": len(allowlist),
        "discovered_apps_allowlist": allowlist,
        "inventory_editors": [{"name": app.get("name", "?")} for app in inventory_editors],
        "inventory_browsers": [{"name": app.get("name", "?")} for app in inventory_browsers],
        "browsers_count": len(inventory_browsers),
        "cursor_found": any(
            "cursor" in app.get("name", "").lower() or "cursor" in app.get("exec", "").lower()
            for app in all_apps
        ),
    }
    if timed_out:
        profile["scan_partial"] = True
        profile["scan_error"] = f"scan_timeout_{SCAN_TIMEOUT_SEC}s"
    profile["installed_apps"] = map_inventory_to_installed_apps(
        meaningful_apps,
        os_family=_detect_os_family(),
    )
    return profile


def _detect_os_family() -> str:
    os_name = (platform.system() or "").lower()
    if os_name == "darwin":
        return "macos"
    return os_name or "linux"


def _scan_deadline_reached(deadline: float | None) -> bool:
    return deadline is not None and time.monotonic() >= deadline


def _windows_program_dirs() -> list[Path]:
    roots: list[Path] = []
    for env_key in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
        raw = os.environ.get(env_key, "").strip()
        if raw:
            path = Path(raw)
            if path.is_dir():
                roots.append(path)
    return roots


def _scan_windows_common_paths(*, deadline: float | None = None) -> list[dict[str, str]]:
    """Best-effort exe discovery under Program Files and LocalAppData (Phase 3c MVP)."""
    apps: list[dict[str, str]] = []
    seen: set[str] = set()
    for root in _windows_program_dirs():
        if _scan_deadline_reached(deadline):
            break
        for pattern in ("*.exe",):
            try:
                matches = root.rglob(pattern)
            except OSError:
                continue
            for exe_path in matches:
                if _scan_deadline_reached(deadline):
                    break
                if len(apps) >= 400:
                    return apps
                name = exe_path.stem
                key = str(exe_path).lower()
                if key in seen:
                    continue
                seen.add(key)
                apps.append(
                    {
                        "name": name,
                        "exec": str(exe_path),
                        "canonical_id": name.lower(),
                        "categories": "",
                        "source_hint": "common_path_scan",
                    }
                )
    return apps


def _scan_windows_registry_apps(*, deadline: float | None = None) -> list[dict[str, str]]:
    """Read Uninstall registry keys when winreg is available (Windows only)."""
    if platform.system().lower() != "windows":
        return []
    try:
        import winreg  # type: ignore[import-not-found]
    except ImportError:
        return []

    apps: list[dict[str, str]] = []
    seen: set[str] = set()
    uninstall_roots = (
        (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
        (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
        (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
    )
    for hive, subkey in uninstall_roots:
        if _scan_deadline_reached(deadline):
            break
        try:
            with winreg.OpenKey(hive, subkey) as root_key:
                index = 0
                while not _scan_deadline_reached(deadline):
                    try:
                        child_name = winreg.EnumKey(root_key, index)
                    except OSError:
                        break
                    index += 1
                    try:
                        with winreg.OpenKey(root_key, child_name) as app_key:
                            display_name, _ = winreg.QueryValueEx(app_key, "DisplayName")
                            install_location = ""
                            try:
                                install_location, _ = winreg.QueryValueEx(app_key, "InstallLocation")
                            except OSError:
                                pass
                            display_icon = ""
                            try:
                                display_icon, _ = winreg.QueryValueEx(app_key, "DisplayIcon")
                            except OSError:
                                pass
                    except OSError:
                        continue
                    name = str(display_name or "").strip()
                    if not name:
                        continue
                    canonical = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
                    key = canonical or name.lower()
                    if key in seen:
                        continue
                    seen.add(key)
                    exec_path = str(display_icon or install_location or "").strip().strip('"')
                    if exec_path.lower().endswith(",0"):
                        exec_path = exec_path[:-2]
                    apps.append(
                        {
                            "name": name,
                            "exec": exec_path,
                            "canonical_id": canonical or name.lower(),
                            "categories": "",
                            "source_hint": "registry_scan",
                        }
                    )
                    if len(apps) >= 400:
                        return apps
        except OSError:
            continue
    return apps


def _scan_windows_installed_apps(*, deadline: float | None = None) -> tuple[list[dict[str, str]], bool]:
    timed_out = False
    merged: dict[str, dict[str, str]] = {}
    for batch in (_scan_windows_registry_apps(deadline=deadline), _scan_windows_common_paths(deadline=deadline)):
        if _scan_deadline_reached(deadline):
            timed_out = True
            break
        for app in batch:
            key = str(app.get("canonical_id") or app.get("name") or "").lower()
            if key and key not in merged:
                merged[key] = app
    apps = sorted(merged.values(), key=lambda item: str(item.get("name") or "").lower())
    return apps, timed_out


def _parse_macos_info_plist(plist_path: Path) -> dict[str, str]:
    try:
        import plistlib
    except ImportError:
        return {}
    try:
        with plist_path.open("rb") as handle:
            payload = plistlib.load(handle)
    except OSError:
        return {}
    if not isinstance(payload, dict):
        return {}
    return {
        "name": str(payload.get("CFBundleName") or payload.get("CFBundleDisplayName") or plist_path.stem),
        "bundle_id": str(payload.get("CFBundleIdentifier") or ""),
        "exec": str(payload.get("CFBundleExecutable") or ""),
    }


def _scan_macos_applications_dir(*, deadline: float | None = None) -> list[dict[str, str]]:
    """Scan /Applications and ~/Applications for .app bundles (Phase 3c MVP)."""
    roots = [Path("/Applications"), Path.home() / "Applications"]
    apps: list[dict[str, str]] = []
    seen: set[str] = set()
    for root in roots:
        if not root.is_dir() or _scan_deadline_reached(deadline):
            continue
        try:
            bundles = sorted(root.glob("*.app"))
        except OSError:
            continue
        for bundle in bundles:
            if _scan_deadline_reached(deadline):
                break
            info = _parse_macos_info_plist(bundle / "Contents" / "Info.plist")
            name = str(info.get("name") or bundle.stem)
            bundle_id = str(info.get("bundle_id") or "")
            key = bundle_id or name.lower()
            if key in seen:
                continue
            seen.add(key)
            exec_name = str(info.get("exec") or "")
            binary_path = str(bundle / "Contents" / "MacOS" / exec_name) if exec_name else str(bundle)
            apps.append(
                {
                    "name": name,
                    "exec": binary_path,
                    "canonical_id": bundle_id or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-"),
                    "categories": "",
                    "bundle_id": bundle_id,
                    "app_bundle": str(bundle),
                    "source_hint": "applications_scan",
                }
            )
            if len(apps) >= 400:
                return apps
    return apps


def _scan_macos_installed_apps(*, deadline: float | None = None) -> tuple[list[dict[str, str]], bool]:
    apps = _scan_macos_applications_dir(deadline=deadline)
    timed_out = _scan_deadline_reached(deadline)
    apps.sort(key=lambda item: str(item.get("name") or "").lower())
    return apps, timed_out


def _normalize_inventory_text(value: str) -> str:
    return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()


def _inventory_app_aliases(app: dict[str, Any]) -> set[str]:
    aliases: set[str] = set()
    for field in ("name", "exec", "canonical_id", "desktop_file"):
        token = _normalize_inventory_text(str(app.get(field) or ""))
        if token:
            aliases.add(token)
            aliases.add(token.replace(" ", "-"))
    desktop_file = str(app.get("desktop_file") or "")
    if desktop_file:
        aliases.add(Path(desktop_file).stem.lower())
    categories = str(app.get("categories") or "").lower()
    for part in re.split(r"[;,\s]+", categories):
        part = part.strip()
        if part:
            aliases.add(part)
    return aliases


def _score_catalog_match(app: dict[str, Any], catalog_app: dict[str, Any]) -> float:
    inventory_match = catalog_app.get("inventory_match")
    if not isinstance(inventory_match, dict):
        return 0.0
    aliases = _inventory_app_aliases(app)
    if not aliases:
        return 0.0

    canonical = str(app.get("canonical_id") or canonical_desktop_stem(app) or "").strip().lower()
    name_norm = _normalize_inventory_text(str(app.get("name") or ""))
    categories = {token for token in aliases if token in _BROWSER_CATEGORY_TOKENS | _EDITOR_CATEGORY_TOKENS}
    categories.update(
        token.strip().lower()
        for token in re.split(r"[;,\s]+", str(app.get("categories") or ""))
        if token.strip()
    )

    best = 0.0
    for stem in inventory_match.get("desktop_stems") or []:
        stem_norm = str(stem or "").strip().lower()
        if not stem_norm:
            continue
        if canonical == stem_norm or stem_norm in aliases:
            best = max(best, 1.0)

    for keyword in inventory_match.get("name_keywords") or []:
        keyword_norm = _normalize_inventory_text(str(keyword or ""))
        if not keyword_norm:
            continue
        if keyword_norm in name_norm or keyword_norm in aliases:
            best = max(best, 0.92)

    for category in inventory_match.get("categories") or []:
        category_norm = str(category or "").strip().lower()
        if category_norm and category_norm in categories:
            best = max(best, 0.85)

    if best > 0 and matches_browser_app(app) and catalog_app.get("app_id") == "web_browser":
        best = max(best, 0.9)
    if best > 0 and matches_editor_app(app) and catalog_app.get("app_id") == "code_editor":
        best = max(best, 0.9)
    if best > 0 and matches_office_app(app) and catalog_app.get("app_id") == "document_writer":
        best = max(best, 0.95)
    return best


def _application_to_installed_entry(
    app: dict[str, Any],
    *,
    app_id: str,
    confidence: float,
    os_family: str,
    catalog_app: dict[str, Any],
) -> dict[str, Any]:
    desktop_file = str(app.get("desktop_file") or "").strip()
    exec_line = str(app.get("exec") or "").strip()
    argv = exec_line.split() if exec_line else []
    desktop_id = Path(desktop_file).stem if desktop_file else str(app.get("canonical_id") or "")
    family = str(os_family or "linux").lower()
    if family == "darwin":
        family = "macos"
    bundle_id = str(app.get("bundle_id") or "").strip()
    if family == "macos" and (bundle_id or str(app.get("app_bundle") or "").endswith(".app")):
        launch_kind = "macos_bundle"
    elif family == "windows":
        launch_kind = "binary"
    else:
        launch_kind = "desktop_entry" if desktop_file else "binary"
    maps_from = catalog_app.get("maps_from")
    alias_list = list(maps_from) if isinstance(maps_from, list) else []
    name = str(app.get("name") or app_id)
    alias_list.extend([name, str(app.get("canonical_id") or ""), desktop_id])
    deduped_aliases = sorted({str(item).strip() for item in alias_list if str(item).strip()})
    categories = [
        token.strip()
        for token in re.split(r"[;,\s]+", str(app.get("categories") or ""))
        if token.strip()
    ]
    source_hint = str(app.get("source_hint") or "").strip().lower()
    if source_hint == "registry_scan" or (family == "windows" and not desktop_file):
        source = "registry_scan"
    else:
        source = "desktop_scan"
    binary_path = argv[0] if argv else None
    launch_spec: dict[str, Any] = {
        "kind": launch_kind,
        "argv": argv,
        "desktop_id": desktop_id,
    }
    if bundle_id:
        launch_spec["bundle_id"] = bundle_id
    return {
        "app_id": app_id,
        "display_name": name,
        "version": None,
        "launch_spec": launch_spec,
        "binary_path": binary_path,
        "confidence": round(float(confidence), 3),
        "detected_at": _utc_now_iso(),
        "os_family": family,
        "aliases": deduped_aliases,
        "source": source,
        "categories": categories,
    }


def map_inventory_to_installed_apps(
    applications: list[dict[str, Any]] | None,
    *,
    os_family: str = "linux",
) -> list[dict[str, Any]]:
    """Map legacy applications[] inventory to canonical installed_apps[] (DSL v0.1)."""
    from iuve_connect_agents.shared.app_resolver import catalog_entry_for_app_id, load_app_catalog

    apps_in = [dict(item) for item in (applications or []) if isinstance(item, dict)]
    family = str(os_family or "linux").lower()
    if family == "darwin":
        family = "macos"

    catalog = load_app_catalog()
    catalog_apps = catalog.get("apps")
    if not isinstance(catalog_apps, list):
        catalog_apps = []

    by_app_id: dict[str, dict[str, Any]] = {}
    for catalog_app in catalog_apps:
        if not isinstance(catalog_app, dict):
            continue
        app_id = str(catalog_app.get("app_id") or "").strip()
        if not app_id:
            continue
        if catalog_app.get("system_action") or catalog_app.get("runtime_builtin"):
            continue
        best_entry: dict[str, Any] | None = None
        best_score = 0.0
        for app in apps_in:
            score = _score_catalog_match(app, catalog_app)
            if score > best_score:
                best_score = score
                best_entry = _application_to_installed_entry(
                    app,
                    app_id=app_id,
                    confidence=score,
                    os_family=family,
                    catalog_app=catalog_app,
                )
        if best_entry is not None and best_score >= 0.6:
            existing = by_app_id.get(app_id)
            if existing is None or float(best_entry.get("confidence") or 0) > float(existing.get("confidence") or 0):
                by_app_id[app_id] = best_entry

    tools = _detect_tools()
    if tools.get("gio"):
        trash_catalog = catalog_entry_for_app_id("trash_manager")
        if trash_catalog is not None:
            by_app_id["trash_manager"] = {
                "app_id": "trash_manager",
                "display_name": "Trash Manager",
                "version": None,
                "launch_spec": {"kind": "shell", "argv": ["gio", "trash", "--empty"]},
                "binary_path": shutil.which("gio"),
                "confidence": 1.0,
                "detected_at": _utc_now_iso(),
                "os_family": family,
                "aliases": list(trash_catalog.get("maps_from") or []),
                "source": "priority_inject",
                "categories": ["system"],
            }

    screenshot_catalog = catalog_entry_for_app_id("screenshot_tool")
    if screenshot_catalog is not None:
        by_app_id.setdefault(
            "screenshot_tool",
            {
                "app_id": "screenshot_tool",
                "display_name": "Screenshot Tool",
                "version": None,
                "launch_spec": {"kind": "binary", "argv": []},
                "binary_path": None,
                "confidence": 1.0,
                "detected_at": _utc_now_iso(),
                "os_family": family,
                "aliases": list(screenshot_catalog.get("maps_from") or []),
                "source": "priority_inject",
                "categories": ["utility"],
            },
        )

    return sorted(by_app_id.values(), key=lambda item: str(item.get("app_id") or ""))


def to_heartbeat_summary(profile: dict[str, Any] | None) -> dict[str, str]:
    if not profile:
        return {
            "device_profile_schema_version": PROFILE_SCHEMA_VERSION,
            "device_profile_present": "false",
        }
    scanned = _parse_scanned_at(profile)
    age_hours = ""
    if scanned is not None:
        age_hours = str(int((datetime.now(timezone.utc) - scanned.astimezone(timezone.utc)).total_seconds() // 3600))
    os_info = profile.get("os") if isinstance(profile.get("os"), dict) else {}
    desktop = profile.get("desktop") if isinstance(profile.get("desktop"), dict) else {}
    return {
        "device_profile_schema_version": str(profile.get("schema_version") or PROFILE_SCHEMA_VERSION),
        "device_profile_present": "true",
        "device_profile_os": f"{os_info.get('name', 'unknown')} {os_info.get('version', '')}".strip(),
        "device_profile_desktop": str(desktop.get("environment") or "unknown"),
        "device_profile_display": str(desktop.get("display_server") or "unknown"),
        "device_profile_apps_count": str(profile.get("applications_count") or 0),
        "device_profile_allowlist_count": str(profile.get("allowlist_count") or 0),
        "device_profile_browsers_count": str(profile.get("browsers_count") or 0),
        "device_profile_cursor_found": "true" if profile.get("cursor_found") else "false",
        "device_profile_scan_age_hours": age_hours,
    }


def format_human_summary_ru(profile: dict[str, Any]) -> str:
    cpu = profile.get("cpu") if isinstance(profile.get("cpu"), dict) else {}
    memory = profile.get("memory") if isinstance(profile.get("memory"), dict) else {}
    os_info = profile.get("os") if isinstance(profile.get("os"), dict) else {}
    desktop = profile.get("desktop") if isinstance(profile.get("desktop"), dict) else {}
    tools = profile.get("tools") if isinstance(profile.get("tools"), dict) else {}
    apps_count = int(profile.get("applications_count") or 0)
    cursor = "да" if profile.get("cursor_found") else "нет"
    tool_names = [name for name, present in tools.items() if present]
    tools_text = ", ".join(tool_names) if tool_names else "нет"
    return (
        f"ОС: {os_info.get('name', '?')} {os_info.get('version', '')}. "
        f"Рабочий стол: {desktop.get('environment', '?')} ({desktop.get('display_server', '?')}). "
        f"CPU: {cpu.get('model', '?')} ({cpu.get('cores', '?')} ядер). "
        f"RAM: {memory.get('available_mb', '?')} / {memory.get('total_mb', '?')} МБ свободно. "
        f"Найдено приложений: {apps_count}. Cursor: {cursor}. "
        f"Инструменты автоматизации: {tools_text}."
    )


_DEVICE_INVENTORY_LEGACY_TOKENS = (
    "что установлено",
    "что у меня установлено",
    "какие программы",
    "какие приложения",
    "список программ",
    "список приложений",
    "what is installed",
    "what apps",
    "installed apps",
    "installed programs",
)
_DEVICE_INVENTORY_APP_NOUNS = (
    "программ",
    "приложен",
    "applications",
    "programs",
    " apps",
    "app ",
)
_DEVICE_INVENTORY_COUNT_MARKERS = (
    "сколько",
    "how many",
    "how much",
    "количество",
    "count of",
)
_DEVICE_INVENTORY_LIST_MARKERS = (
    "перечисл",
    "назови",
    "выведи",
    "покажи список",
    "list all",
    "list the",
    "list installed",
    "enumerate",
    "show all",
    "show installed",
    "все программы",
    "все приложения",
    "all programs",
    "all apps",
    "all applications",
)
_DEVICE_INVENTORY_SHORT_LIST_RE = re.compile(
    r"^(?:перечисл\w*|список|назови(?:\s+их)?|покажи(?:\s+список)?|list|show)\s*[.!?…]*$",
    re.IGNORECASE,
)
_DEVICE_INVENTORY_PRIOR_HINTS = (
    "программ",
    "приложен",
    "applications",
    "programs",
    " apps",
    "app ",
    "сколько программ",
    "сколько приложений",
)
_BROWSER_INVENTORY_NOUNS = (
    "браузер",
    "browser",
    "browsers",
)
_EDITOR_INVENTORY_NOUNS = (
    "редактор",
    "editor",
    "editors",
    "ide",
)
_DEVICE_INVENTORY_VISIBILITY_MARKERS = (
    "видишь",
    "видите",
    "видно",
    "обнаруж",
    "найден",
    "see",
    "detect",
    "found on",
)
_EDITOR_APP_KEYWORDS = (
    "cursor",
    "vscode",
    "visual studio code",
    "libreoffice",
    "onlyoffice",
    "gedit",
    "kate",
    "sublime",
    "atom",
    "neovim",
    "vim",
    "code",
)
_BROWSER_APP_KEYWORDS = (
    "firefox",
    "mozilla-firefox",
    "google-chrome",
    "google chrome",
    "chromium",
    "chromium-browser",
    "brave",
    "brave-browser",
    "opera",
    "vivaldi",
    "microsoft-edge",
    "microsoft edge",
    "msedge",
    "waterfox",
    "librewolf",
)


def _mentions_installed_apps(lower: str) -> bool:
    return any(noun in lower for noun in _DEVICE_INVENTORY_APP_NOUNS)


def _is_app_launch_inventory_exclusion(text: str) -> bool:
    from ..ui.avatar_intent_router import is_generic_app_launch_intent, wants_local_cursor_launch

    return is_generic_app_launch_intent(text) or wants_local_cursor_launch(text)


def is_device_inventory_count_question(text: str) -> bool:
    """True when the user asks how many apps are installed (count only)."""
    if not is_device_inventory_question(text):
        return False
    lower = str(text or "").strip().lower()
    return any(marker in lower for marker in _DEVICE_INVENTORY_COUNT_MARKERS)


def is_device_editors_browsers_inventory_question(text: str) -> bool:
    """True when the user asks which editors and/or browsers are on the device."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if _is_app_launch_inventory_exclusion(text):
        return False
    has_browser = any(noun in lower for noun in _BROWSER_INVENTORY_NOUNS)
    has_editor = any(noun in lower for noun in _EDITOR_INVENTORY_NOUNS)
    if not has_browser and not has_editor:
        return False
    list_intent = (
        any(marker in lower for marker in _DEVICE_INVENTORY_LIST_MARKERS)
        or any(marker in lower for marker in _DEVICE_INVENTORY_VISIBILITY_MARKERS)
        or any(marker in lower for marker in _DEVICE_INVENTORY_COUNT_MARKERS)
        or lower.startswith("какие ")
        or lower.startswith("какой ")
        or lower.startswith("which ")
    )
    if has_browser and has_editor:
        return list_intent
    if has_browser:
        return is_device_browser_inventory_question(text) or list_intent
    return list_intent


def is_device_browser_inventory_question(text: str) -> bool:
    """True when the user asks to count or list installed browsers on the device."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if _is_app_launch_inventory_exclusion(text):
        return False
    if not any(noun in lower for noun in _BROWSER_INVENTORY_NOUNS):
        return False
    if any(marker in lower for marker in _DEVICE_INVENTORY_COUNT_MARKERS):
        return True
    if any(marker in lower for marker in _DEVICE_INVENTORY_LIST_MARKERS):
        return True
    if any(marker in lower for marker in _DEVICE_INVENTORY_VISIBILITY_MARKERS):
        return True
    if lower.startswith("какие ") or lower.startswith("какой ") or lower.startswith("which "):
        return True
    if any(token in lower for token in _DEVICE_INVENTORY_LEGACY_TOKENS):
        return True
    return False


def expand_device_inventory_follow_up(text: str, *, prior_user_text: str | None = None) -> str:
    """Expand short list follow-ups after a prior installed-apps question."""
    raw = str(text or "").strip()
    prior = str(prior_user_text or "").strip()
    if not raw or not prior:
        return raw
    if not _DEVICE_INVENTORY_SHORT_LIST_RE.match(raw):
        return raw
    prior_lower = prior.lower()
    if not any(hint in prior_lower for hint in _DEVICE_INVENTORY_PRIOR_HINTS):
        return raw
    return "Перечисли все программы на этом устройстве"


def is_device_inventory_question(text: str) -> bool:
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if _is_app_launch_inventory_exclusion(text):
        return False
    if is_device_editors_browsers_inventory_question(text):
        return True
    if is_device_browser_inventory_question(text):
        return True
    if any(token in lower for token in _DEVICE_INVENTORY_LEGACY_TOKENS):
        return True
    if not _mentions_installed_apps(lower):
        return False
    if any(marker in lower for marker in _DEVICE_INVENTORY_COUNT_MARKERS):
        return True
    if any(marker in lower for marker in _DEVICE_INVENTORY_LIST_MARKERS):
        return True
    return False


def _normalize_inventory_display_name(name: str) -> str:
    cleaned = re.sub(r"\([^)]*\)", "", str(name or ""))
    return _normalize_match_text(cleaned)


def _is_junk_inventory_app(app: dict[str, Any], *, browsers_question: bool = False) -> bool:
    return is_junk_inventory_app(app, browsers_question=browsers_question)


def _matches_office_app(app: dict[str, Any]) -> bool:
    return matches_office_app(app)


def _matches_messenger_app(app: dict[str, Any]) -> bool:
    return matches_messenger_app(app)


def _filter_meaningful_inventory_apps(
    apps: list[dict[str, Any]],
    *,
    browsers_question: bool = False,
) -> list[dict[str, Any]]:
    return _filter_meaningful_inventory_apps_shared(
        apps,
        browsers_question=browsers_question,
        priority_score_fn=_app_priority_score,
    )


def _meaningful_apps_from_profile(
    profile: dict[str, Any] | None,
    text: str = "",
) -> list[dict[str, Any]]:
    browsers_question = is_device_browser_inventory_question(text) or is_device_editors_browsers_inventory_question(
        text
    )
    apps = _profile_scan_apps(profile)
    return _filter_meaningful_inventory_apps(apps, browsers_question=browsers_question)


def _installed_apps_count(profile: dict[str, Any] | None, text: str = "") -> int:
    return len(_meaningful_apps_from_profile(profile, text))


def _format_inventory_app_lines_ru(apps: list[dict[str, Any]]) -> list[str]:
    if not apps:
        return []
    capped = apps[:_INVENTORY_DISPLAY_CAP]
    category_sections = [
        ("Браузеры", _matches_browser_app),
        ("Редакторы и IDE", _matches_editor_app),
        ("Офисные программы", _matches_office_app),
        ("Мессенджеры", _matches_messenger_app),
    ]
    matched_keys: set[str] = set()
    grouped_lines: list[str] = []
    populated_sections = 0
    for title, matcher in category_sections:
        group = [app for app in capped if matcher(app)]
        if not group:
            continue
        populated_sections += 1
        grouped_lines.append(f"{title} ({len(group)}):")
        for app in group:
            name = str(app.get("name") or "?").strip()
            grouped_lines.append(f"  • {name}")
            matched_keys.add(_normalize_inventory_display_name(name))
    if populated_sections >= 2:
        other = [
            app
            for app in capped
            if _normalize_inventory_display_name(str(app.get("name") or "")) not in matched_keys
        ]
        if other:
            grouped_lines.append(f"Другие приложения ({len(other)}):")
            for app in other:
                grouped_lines.append(f"  • {app.get('name', '?')}")
        return grouped_lines
    lines = [f"Ключевые приложения ({len(capped)}):"]
    for app in capped:
        lines.append(f"  • {app.get('name', '?')}")
    if len(apps) > _INVENTORY_DISPLAY_CAP:
        lines.append(f"  … и ещё {len(apps) - _INVENTORY_DISPLAY_CAP}.")
    return lines


def format_installed_apps_count_answer_ru(profile: dict[str, Any] | None, text: str = "") -> str:
    if not profile:
        return _empty_inventory_profile_message_ru()
    meaningful = _meaningful_apps_from_profile(profile, text)
    header = f"Источник: сканирование устройства · {_format_scanned_at_display_ru(profile)}"
    if not meaningful:
        return (
            f"{header}\n\n"
            "В профиле пока нет данных об установленных приложениях. Сканирование может быть в процессе.\n\n"
            f"{_inventory_refresh_cta_ru()}"
        )
    return (
        f"{header}\n\n"
        f"На устройстве обнаружено {len(meaningful)} ключевых приложений.\n\n"
        f"{_inventory_refresh_cta_ru()}"
    )


def format_installed_apps_answer_ru(profile: dict[str, Any] | None, text: str = "") -> str:
    if not profile:
        return _empty_inventory_profile_message_ru()
    meaningful = _meaningful_apps_from_profile(profile, text)
    header = f"Источник: сканирование устройства · {_format_scanned_at_display_ru(profile)}"
    if not meaningful:
        return (
            f"{header}\n\n"
            "В профиле пока нет списка приложений. Сканирование может быть в процессе.\n\n"
            f"{_inventory_refresh_cta_ru()}"
        )
    lines = [header, "", f"Установленные приложения ({len(meaningful)}):"]
    lines.extend(_format_inventory_app_lines_ru(meaningful))
    lines.extend(["", _inventory_refresh_cta_ru()])
    return "\n".join(lines)


def _format_scanned_at_display_ru(profile: dict[str, Any]) -> str:
    scanned = _parse_scanned_at(profile)
    if scanned is None:
        return "время неизвестно"
    local = scanned.astimezone()
    return local.strftime("%d.%m.%Y %H:%M")


def _inventory_refresh_cta_ru() -> str:
    return "Обновить скан: Диагностика → «Обновить сканирование»"


def _empty_inventory_profile_message_ru() -> str:
    return (
        "Профиль устройства ещё не собран. Подождите минуту после подключения или обновите скан.\n\n"
        f"{_inventory_refresh_cta_ru()}"
    )


def _stale_inventory_profile_message_ru(profile: dict[str, Any]) -> str:
    scanned_at = _format_scanned_at_display_ru(profile)
    return (
        f"Данные сканирования устарели (последний скан: {scanned_at}). "
        "Обновите профиль, чтобы увидеть актуальный список приложений.\n\n"
        f"{_inventory_refresh_cta_ru()}"
    )


def _desktop_app_match_blob(app: dict[str, Any]) -> str:
    desktop_file = str(app.get("desktop_file") or "").strip()
    desktop_path = Path(desktop_file) if desktop_file else None
    desktop_id = str(app.get("desktop_id") or (desktop_path.name if desktop_path else "")).lower()
    desktop_stem = desktop_path.stem.lower() if desktop_path else ""
    return (
        f"{app.get('name', '')} {app.get('exec', '')} {desktop_id} {desktop_stem}"
    ).lower()


def _desktop_stem_tokens(app: dict[str, Any]) -> set[str]:
    desktop_file = str(app.get("desktop_file") or "").strip()
    desktop_id = str(app.get("desktop_id") or Path(desktop_file).name if desktop_file else "").lower()
    stem = Path(desktop_file).stem.lower() if desktop_file else ""
    tokens = {item for item in (desktop_id, stem) if item}
    for item in (desktop_id, stem):
        if item.endswith(".desktop"):
            tokens.add(item[: -len(".desktop")])
    return tokens


def _app_category_tokens(app: dict[str, Any]) -> set[str]:
    return {item.strip().lower() for item in str(app.get("categories") or "").split(";") if item.strip()}


def _is_ai_provider_app(app: dict[str, Any]) -> bool:
    blob = f"{app.get('name', '')} {app.get('exec', '')} {app.get('desktop_file', '')}".lower()
    return any(pattern in blob for pattern in _AI_PROVIDER_APP_PATTERNS)


def _inventory_app_key(app: dict[str, Any]) -> str:
    desktop_file = str(app.get("desktop_file") or "").strip().lower()
    if desktop_file:
        return desktop_file
    name = str(app.get("name") or "").strip().lower()
    exec_line = str(app.get("exec") or "").strip().lower()
    return f"{name}|{exec_line}"


def _dedupe_inventory_apps(apps: list[dict[str, Any]]) -> list[dict[str, Any]]:
    deduped: list[dict[str, Any]] = []
    seen: set[str] = set()
    for app in apps:
        if not isinstance(app, dict):
            continue
        key = _inventory_app_key(app)
        if not key or key in seen:
            continue
        seen.add(key)
        deduped.append(app)
    deduped.sort(key=lambda item: str(item.get("name") or "").lower())
    return deduped


def _profile_scan_apps(profile: dict[str, Any] | None) -> list[dict[str, Any]]:
    if not profile:
        return []
    allowlist = profile.get("discovered_apps_allowlist")
    if isinstance(allowlist, list) and allowlist:
        apps: list[dict[str, Any]] = []
        for entry in allowlist:
            if not isinstance(entry, dict):
                continue
            desktop_file = str(entry.get("desktop_file") or "").strip()
            apps.append(
                {
                    "name": str(entry.get("name") or "").strip(),
                    "exec": str(entry.get("exec") or "").strip(),
                    "desktop_file": desktop_file,
                    "desktop_id": str(entry.get("desktop_id") or Path(desktop_file).name if desktop_file else ""),
                    "categories": str(entry.get("categories") or "").strip(),
                }
            )
        if apps:
            return apps
    apps = profile.get("applications")
    if isinstance(apps, list):
        return [dict(item) for item in apps if isinstance(item, dict)]
    return []


def is_device_scan_in_progress() -> bool:
    return get_scan_scheduler().is_scan_running()


def format_device_inventory_scan_in_progress_ru() -> str:
    return (
        "Сканирование профиля устройства… Подождите несколько секунд и повторите вопрос.\n\n"
        f"{_inventory_refresh_cta_ru()}"
    )


def _inventory_names_from_profile(profile: dict[str, Any], field: str) -> list[str]:
    raw = profile.get(field)
    if not isinstance(raw, list):
        return []
    names: list[str] = []
    for item in raw:
        if isinstance(item, dict):
            name = str(item.get("name") or "").strip()
            if name:
                names.append(name)
        elif isinstance(item, str) and item.strip():
            names.append(item.strip())
    return names


def _matches_editor_app(app: dict[str, Any]) -> bool:
    return matches_editor_app(app)


def _matches_browser_app(app: dict[str, Any]) -> bool:
    return matches_browser_app(app)


def _browsers_from_apps(apps: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return _browsers_from_apps_filtered(apps)


def _editors_from_apps(apps: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return _editors_from_apps_filtered(apps)


def _browsers_from_profile(profile: dict[str, Any] | None) -> list[dict[str, Any]]:
    if not profile:
        return []
    apps = _profile_scan_apps(profile)
    if apps:
        browsers = _browsers_from_apps(apps)
        if browsers:
            return browsers
    cached_names = _inventory_names_from_profile(profile, "inventory_browsers")
    if cached_names:
        return [{"name": name} for name in cached_names]
    return []


def format_browser_inventory_answer_ru(text: str, profile: dict[str, Any] | None) -> str:
    """Advisory answer for installed-browser inventory NL (count vs list)."""
    if is_device_scan_in_progress():
        return format_device_inventory_scan_in_progress_ru()
    if not profile:
        return _empty_inventory_profile_message_ru()
    if is_profile_stale(profile):
        return _stale_inventory_profile_message_ru(profile)
    browsers = _browsers_from_profile(profile)
    if not browsers:
        return (
            "В профиле пока не найдено браузеров. Сканирование может быть в процессе.\n\n"
            f"Источник: сканирование устройства · {_format_scanned_at_display_ru(profile)}\n\n"
            f"{_inventory_refresh_cta_ru()}"
        )
    lower = str(text or "").strip().lower()
    header = f"Источник: сканирование устройства · {_format_scanned_at_display_ru(profile)}"
    if any(marker in lower for marker in _DEVICE_INVENTORY_COUNT_MARKERS):
        return f"{header}\n\nНа устройстве обнаружено {len(browsers)} браузер(ов).\n\n{_inventory_refresh_cta_ru()}"
    lines = [header, "", f"Браузеры ({len(browsers)}):"]
    for index, app in enumerate(browsers[:10], start=1):
        lines.append(f"  • {app.get('name', '?')}")
    if len(browsers) > 10:
        lines.append(f"  … и ещё {len(browsers) - 10}.")
    lines.extend(["", _inventory_refresh_cta_ru()])
    return "\n".join(lines)


def _editors_from_profile(profile: dict[str, Any] | None) -> list[dict[str, Any]]:
    if not profile:
        return []
    apps = _profile_scan_apps(profile)
    if apps:
        editors = _editors_from_apps(apps)
        if editors:
            return editors
    cached_names = _inventory_names_from_profile(profile, "inventory_editors")
    if cached_names:
        return [{"name": name} for name in cached_names]
    return []


def format_editors_browsers_inventory_answer_ru(text: str, profile: dict[str, Any] | None) -> str:
    """Deterministic advisory answer listing installed editors and browsers from the device profile."""
    if is_device_scan_in_progress():
        return format_device_inventory_scan_in_progress_ru()
    if not profile:
        return _empty_inventory_profile_message_ru()
    if is_profile_stale(profile):
        return _stale_inventory_profile_message_ru(profile)
    lower = str(text or "").strip().lower()
    has_browser = any(noun in lower for noun in _BROWSER_INVENTORY_NOUNS)
    has_editor = any(noun in lower for noun in _EDITOR_INVENTORY_NOUNS)
    browsers = _browsers_from_profile(profile) if has_browser else []
    editors = _editors_from_profile(profile) if has_editor else []
    header = f"Источник: сканирование устройства · {_format_scanned_at_display_ru(profile)}"
    if not browsers and not editors:
        return (
            f"{header}\n\n"
            "В профиле пока нет данных о редакторах и браузерах. Сканирование может быть в процессе.\n\n"
            f"{_inventory_refresh_cta_ru()}"
        )
    lines: list[str] = [header, ""]
    if has_editor:
        if editors:
            lines.append(f"Редакторы и IDE ({len(editors)}):")
            for app in editors[:10]:
                lines.append(f"  • {app.get('name', '?')}")
            if len(editors) > 10:
                lines.append(f"  … и ещё {len(editors) - 10}.")
        else:
            lines.append("Редакторы и IDE: в профиле пока не найдены.")
    if has_browser:
        if browsers:
            lines.append(f"Браузеры ({len(browsers)}):")
            for app in browsers[:10]:
                lines.append(f"  • {app.get('name', '?')}")
            if len(browsers) > 10:
                lines.append(f"  … и ещё {len(browsers) - 10}.")
        else:
            lines.append("Браузеры: в профиле пока не найдены.")
    lines.extend(["", _inventory_refresh_cta_ru()])
    return "\n".join(lines)


def format_device_inventory_answer_ru(text: str, profile: dict[str, Any] | None) -> str:
    """Advisory answer for installed-apps inventory NL (count vs list)."""
    if is_device_scan_in_progress():
        return format_device_inventory_scan_in_progress_ru()
    if is_device_editors_browsers_inventory_question(text):
        return format_editors_browsers_inventory_answer_ru(text, profile)
    if is_device_browser_inventory_question(text):
        return format_browser_inventory_answer_ru(text, profile)
    if not profile:
        return _empty_inventory_profile_message_ru()
    if is_profile_stale(profile):
        return _stale_inventory_profile_message_ru(profile)
    if is_device_inventory_count_question(text):
        return format_installed_apps_count_answer_ru(profile, text)
    return format_installed_apps_answer_ru(profile, text)


def cursor_launch_candidates_from_profile(profile: dict[str, Any] | None) -> tuple[tuple[str, ...], ...] | None:
    if not profile:
        return None
    apps = profile.get("applications")
    if not isinstance(apps, list):
        return None
    candidates: list[tuple[str, ...]] = []
    for app in apps:
        if not isinstance(app, dict):
            continue
        name = str(app.get("name") or "").lower()
        exec_line = str(app.get("exec") or "").strip()
        desktop_file = str(app.get("desktop_file") or "").strip()
        if "cursor" not in name and "cursor" not in exec_line.lower():
            continue
        if desktop_file:
            candidates.append(("gtk-launch", Path(desktop_file).stem))
        if exec_line:
            parts = exec_line.split()
            if parts:
                candidates.append(tuple(parts))
    if desktop_file := _find_cursor_desktop_by_glob():
        candidates.append(("gtk-launch", desktop_file.stem))
    deduped: list[tuple[str, ...]] = []
    seen: set[tuple[str, ...]] = set()
    for item in candidates:
        if item not in seen:
            seen.add(item)
            deduped.append(item)
    return tuple(deduped) if deduped else None


def firefox_launch_candidates_from_profile(profile: dict[str, Any] | None) -> tuple[tuple[str, ...], ...] | None:
    if not profile:
        return None
    apps = profile.get("applications")
    if not isinstance(apps, list):
        return None
    candidates: list[tuple[str, ...]] = []
    for app in apps:
        if not isinstance(app, dict):
            continue
        name = str(app.get("name") or "").lower()
        exec_line = str(app.get("exec") or "").strip()
        desktop_file = str(app.get("desktop_file") or "").strip()
        blob = f"{name} {exec_line.lower()}"
        if "firefox" not in blob and "mozilla" not in blob:
            continue
        if desktop_file:
            candidates.append(("gtk-launch", Path(desktop_file).stem))
        if exec_line:
            parts = exec_line.split()
            if parts:
                candidates.append(tuple(parts))
    if desktop_file := _find_firefox_desktop_by_glob():
        candidates.append(("gtk-launch", desktop_file.stem))
    deduped: list[tuple[str, ...]] = []
    seen: set[tuple[str, ...]] = set()
    for item in candidates:
        if item not in seen:
            seen.add(item)
            deduped.append(item)
    return tuple(deduped) if deduped else None


def _normalize_match_text(value: str) -> str:
    cleaned = re.sub(r"[^a-z0-9а-яё]+", " ", str(value or "").lower())
    return " ".join(cleaned.split())


def _slugify_app_name(name: str) -> str:
    cleaned = re.sub(r"[^a-z0-9]+", "-", str(name or "").lower()).strip("-")
    return cleaned or "app"


_APP_QUERY_ALIASES: dict[str, str] = {
    "телеграм": "telegram",
    "telegram": "telegram",
    "вайбер": "viber",
    "viber": "viber",
    "клауд": "claude",
    "claude": "claude",
    "firefox": "firefox",
    "фаерфокс": "firefox",
    "фаерфокса": "firefox",
    "фаерфоксе": "firefox",
    "лиса": "firefox",
    "лису": "firefox",
    "лисы": "firefox",
    "мозилла": "firefox",
    "mozilla": "firefox",
    "системный монитор": "gnome-system-monitor",
    "system monitor": "gnome-system-monitor",
    "монитор linux": "gnome-system-monitor",
    "монитор": "gnome-system-monitor",
    "monitor": "gnome-system-monitor",
    "stacer": "stacer",
    "стейсер": "stacer",
    "стейсер": "stacer",
}

_SYSTEM_MONITOR_BINARIES: tuple[tuple[str, str], ...] = (
    ("gnome-system-monitor", "GNOME System Monitor"),
    ("plasma-systemmonitor", "KDE System Monitor"),
    ("mate-system-monitor", "MATE System Monitor"),
    ("xfce4-taskmanager", "XFCE Task Manager"),
    ("stacer", "Stacer"),
    ("htop", "htop"),
    ("top", "top"),
)


def normalize_app_query_aliases(query: str) -> str:
    cleaned = _normalize_match_text(query)
    if not cleaned:
        return cleaned
    if cleaned in _APP_QUERY_ALIASES:
        return _APP_QUERY_ALIASES[cleaned]
    for alias, canonical in _APP_QUERY_ALIASES.items():
        if alias in cleaned:
            return canonical
    return cleaned


def fuzzy_match_installed_app(query: str, profile: dict[str, Any] | None) -> dict[str, Any] | None:
    """Fuzzy-match NL app name against scanned desktop applications."""
    needle = normalize_app_query_aliases(query)
    if not needle or not profile:
        return None
    apps = profile.get("applications")
    if not isinstance(apps, list):
        return None

    best: dict[str, Any] | None = None
    best_score = 0.0
    for app in apps:
        if not isinstance(app, dict):
            continue
        name = str(app.get("name") or "")
        exec_line = str(app.get("exec") or "")
        haystack = _normalize_match_text(f"{name} {exec_line}")
        name_norm = _normalize_match_text(name)
        score = 0.0
        if needle == name_norm:
            score = 100.0
        elif needle in haystack or name_norm in needle:
            score = 85.0
        else:
            score = difflib.SequenceMatcher(a=needle, b=name_norm).ratio() * 75.0
            for token in needle.split():
                if len(token) >= 3 and token in haystack:
                    score += 12.0
        if score > best_score:
            best_score = score
            best = dict(app)

    if best is None or best_score < 35.0:
        return None
    best["slug"] = _slugify_app_name(str(best.get("name") or query))
    best["match_score"] = round(best_score, 2)
    return best


def app_launch_candidates_from_matched_app(app: dict[str, Any]) -> tuple[tuple[str, ...], ...]:
    """Build launch command candidates from a matched desktop entry."""
    candidates: list[tuple[str, ...]] = []
    desktop_file = str(app.get("desktop_file") or "").strip()
    exec_line = str(app.get("exec") or "").strip()
    if desktop_file:
        candidates.append(("gtk-launch", Path(desktop_file).stem))
    if exec_line:
        parts = exec_line.split()
        if parts:
            candidates.append(tuple(parts))
    deduped: list[tuple[str, ...]] = []
    seen: set[tuple[str, ...]] = set()
    for item in candidates:
        if item not in seen:
            seen.add(item)
            deduped.append(item)
    return tuple(deduped)


def resolve_launch_candidates_for_application(
    application: str,
    query: str,
    profile: dict[str, Any] | None,
    *,
    static_candidates: dict[str, tuple[tuple[str, ...], ...]] | None = None,
) -> tuple[tuple[str, ...], ...] | None:
    """Resolve launch commands from dynamic allowlist + static fallbacks.

    Security: only .desktop-resolved entries; arbitrary binaries are rejected upstream.
    Does NOT check application_control — PolicyEngine enforces grants separately.
    Returns None when the app is not installed / not yet discovered by scan.
    """
    normalized = normalize_app_query_aliases(application or query)
    search_text = query or application or normalized

    matched = fuzzy_match_installed_app(search_text, profile)
    if matched is not None:
        candidates = app_launch_candidates_from_matched_app(matched)
        if candidates:
            return candidates

    if profile and normalized:
        allowlist = profile.get("discovered_apps_allowlist")
        if isinstance(allowlist, list):
            best_entry: dict[str, Any] | None = None
            best_score = 0.0
            for entry in allowlist:
                if not isinstance(entry, dict):
                    continue
                slug = str(entry.get("slug") or "")
                name = _normalize_match_text(str(entry.get("name") or ""))
                score = 0.0
                if normalized == slug or normalized == name:
                    score = 100.0
                elif normalized in name or name in normalized:
                    score = 85.0
                elif slug and normalized in slug:
                    score = 80.0
                if score > best_score:
                    best_score = score
                    best_entry = entry
            if best_entry is not None and best_score >= 35.0:
                candidates = app_launch_candidates_from_matched_app(best_entry)
                if candidates:
                    return candidates

    static = static_candidates or {}
    if normalized and normalized in static:
        return static[normalized]
    return None


_KNOWN_BINARY_APPS: dict[str, tuple[str, str]] = {
    "stacer": ("stacer", "Stacer"),
    "htop": ("htop", "htop"),
    "top": ("top", "top"),
}


def resolve_binary_app_launch(query: str) -> tuple[dict[str, Any], tuple[tuple[str, ...], ...]] | None:
    import shutil

    normalized = normalize_app_query_aliases(query)
    entry = _KNOWN_BINARY_APPS.get(normalized)
    if entry is None:
        return None
    binary, display_name = entry
    path = shutil.which(binary)
    if not path:
        return None
    matched = {"name": display_name, "slug": binary, "exec": path}
    return matched, ((path,),)


def resolve_system_monitor_launch() -> tuple[dict[str, Any], tuple[tuple[str, ...], ...]] | None:
    """Try known Linux system monitor binaries in priority order."""
    import shutil

    for binary, display_name in _SYSTEM_MONITOR_BINARIES:
        path = shutil.which(binary)
        if not path:
            continue
        matched = {
            "name": display_name,
            "slug": binary,
            "exec": path,
        }
        if binary.endswith("-monitor") or binary in {"stacer", "xfce4-taskmanager"}:
            candidates: tuple[tuple[str, ...], ...] = ((path,),)
        else:
            candidates = ((binary,), (path,))
        return matched, candidates
    return None


def resolve_app_launch_from_text(
    text: str,
    profile: dict[str, Any] | None,
) -> tuple[dict[str, Any], tuple[tuple[str, ...], ...]] | None:
    """Resolve NL app launch text to a scanned app and launch candidates."""
    from ..ui.avatar_intent_router import (
        extract_app_query_from_text,
        is_chatgpt_browser_intent,
        is_generic_app_launch_intent,
        wants_browser_open_and_type,
        wants_local_cursor_launch,
    )

    if is_chatgpt_browser_intent(text) or wants_browser_open_and_type(text):
        return None
    if not is_generic_app_launch_intent(text) and not wants_local_cursor_launch(text):
        return None
    query = extract_app_query_from_text(text)
    if not query and wants_local_cursor_launch(text):
        query = "cursor"
    if query:
        query = normalize_app_query_aliases(query)
    if not query:
        return None
    matched = fuzzy_match_installed_app(query, profile)
    if matched is None:
        binary = resolve_binary_app_launch(query)
        if binary is not None:
            return binary
    if matched is None and any(
        token in str(text or "").lower()
        for token in ("монитор", "monitor", "системн")
    ):
        return resolve_system_monitor_launch()
    if matched is None:
        return None
    candidates = app_launch_candidates_from_matched_app(matched)
    if not candidates:
        return None
    return matched, candidates


def dump_scan_debug(*, deadline: float | None = None) -> dict[str, Any]:
    """Return raw desktop scan inventory for owner diagnostics."""
    all_apps, timed_out = _scan_all_desktop_apps(deadline=deadline)
    meaningful = _filter_meaningful_inventory_apps(all_apps)
    return {
        "schema_version": PROFILE_SCHEMA_VERSION,
        "scan_partial": timed_out,
        "desktop_search_dirs": [str(path) for path in _desktop_search_dirs()],
        "discovered_count": len(all_apps),
        "meaningful_count": len(meaningful),
        "desktop_ids": sorted(
            {
                str(app.get("desktop_id") or Path(str(app.get("desktop_file") or "")).name)
                for app in all_apps
                if isinstance(app, dict)
            }
        ),
        "canonical_ids": sorted(
            {canonical_desktop_stem(app) for app in all_apps if isinstance(app, dict) and canonical_desktop_stem(app)}
        ),
        "inventory_editors": [app.get("name") for app in _editors_from_apps(meaningful)],
        "inventory_browsers": [app.get("name") for app in _browsers_from_apps(meaningful)],
        "apps": all_apps,
    }


def _find_cursor_desktop_by_glob() -> Path | None:
    for directory in _desktop_search_dirs():
        try:
            for path in directory.glob("*cursor*.desktop"):
                if path.is_file():
                    return path
        except OSError:
            continue
    return None


def _find_firefox_desktop_by_glob() -> Path | None:
    for directory in _desktop_search_dirs():
        try:
            for pattern in ("firefox.desktop", "firefox-esr.desktop"):
                path = directory / pattern
                if path.is_file():
                    return path
            for path in directory.glob("firefox*.desktop"):
                if path.is_file() and "firefox" in path.stem.lower():
                    return path
        except OSError:
            continue
    return None


ProfileCallback = Callable[[dict[str, Any]], None]

_GLOBAL_SCHEDULER: "DeviceCapabilityScanScheduler | None" = None
_GLOBAL_SCHEDULER_INIT = threading.Lock()


def get_scan_scheduler() -> "DeviceCapabilityScanScheduler":
    """Process-wide scan scheduler — debounces triggers from UI and daemon."""
    global _GLOBAL_SCHEDULER
    with _GLOBAL_SCHEDULER_INIT:
        if _GLOBAL_SCHEDULER is None:
            _GLOBAL_SCHEDULER = DeviceCapabilityScanScheduler()
        return _GLOBAL_SCHEDULER


def trusted_pair_scan_eligible(config_path: Path | str | None = None) -> bool:
    """Lightweight paired+trusted check using local evidence (no network, no full agent)."""
    from ...core.runtime import RuntimeSnapshot
    from ...core.storage import JsonFileSecureStorage
    from ..status_resolver import RuntimeEvidenceStore, resolve_canonical_status

    cfg_path = (
        Path(config_path).expanduser()
        if config_path
        else Path.home() / ".config" / "iuve-connect-agent" / "desktop-config.json"
    )
    if not cfg_path.exists():
        return False
    try:
        data = json.loads(cfg_path.read_text(encoding="utf-8"))
        storage_path = Path(
            data.get("storage_path") or Path.home() / ".config" / "iuve-connect-agent" / "desktop-state.json"
        )
        storage = JsonFileSecureStorage(storage_path)
        if storage.load_state() is None:
            return False
        evidence_path = storage_path.expanduser().resolve().with_name("desktop-runtime-evidence.json")
        evidence = RuntimeEvidenceStore(evidence_path).load()
        config_block = dict(data.get("agent", data))
        canonical = resolve_canonical_status(
            runtime_snapshot=RuntimeSnapshot(),
            runtime_evidence=evidence,
            legacy_fields={},
            backend_fields={},
            paired=True,
            backend_url=str(config_block.get("backend_base_url") or ""),
            app_version=str(config_block.get("app_version") or ""),
            update_channel=str(data.get("update_channel") or config_block.get("update_channel") or "stable"),
            logs_path=str(data.get("log_path") or ""),
            storage_path=str(storage_path),
        )
        return str(canonical.trust_state or "").strip().lower() == "trusted"
    except Exception:
        return False


def _profile_on_scan_failure(
    *,
    scan_reason: str,
    error: str,
    profile_path: Path | None,
    partial: dict[str, Any] | None = None,
) -> dict[str, Any]:
    existing = load_profile(profile_path) or {}
    profile = dict(existing) if existing else {}
    profile["scan_error"] = error
    profile["scan_reason"] = str(scan_reason or "scheduled")
    if isinstance(partial, dict):
        for key in (
            "schema_version",
            "cpu",
            "memory",
            "disk",
            "os",
            "desktop",
            "workspace",
            "tools",
            "applications",
            "applications_count",
            "allowlist_count",
            "discovered_apps_allowlist",
            "inventory_editors",
            "inventory_browsers",
            "browsers_count",
            "cursor_found",
            "scan_partial",
        ):
            if key in partial:
                profile[key] = partial[key]
        if partial.get("scan_partial"):
            profile["scan_partial"] = True
    if not existing and not partial:
        profile.update(
            {
                "schema_version": PROFILE_SCHEMA_VERSION,
                "scanned_at": _utc_now_iso(),
                "applications": [],
                "applications_count": 0,
                "cursor_found": False,
            }
        )
    return profile


def _dispatch_on_main_loop(callback: ProfileCallback, profile: dict[str, Any]) -> None:
    def _invoke() -> bool:
        try:
            callback(profile)
        except Exception:
            pass
        return False

    try:
        from gi.repository import GLib

        GLib.idle_add(_invoke)
    except Exception:
        _invoke()


@dataclass
class DeviceCapabilityScanScheduler:
    """Background scanner — does not block GTK main loop."""

    on_profile_updated: ProfileCallback | None = None
    on_apps_discovered: Callable[[list[dict[str, Any]]], None] | None = None
    profile_path: Path | None = None

    def __post_init__(self) -> None:
        self._lock = threading.Lock()
        self._thread: threading.Thread | None = None
        self._scanning = False
        self._pending_force = False
        self._pending_reason = ""

    def is_scan_running(self) -> bool:
        with self._lock:
            if self._scanning:
                return True
            return self._thread is not None and self._thread.is_alive()

    def schedule_if_needed(self, *, force: bool = False, reason: str = "") -> bool:
        with self._lock:
            if self._thread is not None and self._thread.is_alive():
                self._pending_force = self._pending_force or force
                if reason:
                    self._pending_reason = reason
                return False
            profile = load_profile(self.profile_path)
            if not force and profile is not None and not is_profile_stale(profile):
                return False
            self._start_scan_thread(reason or "scheduled")
            return True

    def _start_scan_thread(self, reason: str) -> None:
        self._scanning = True
        _log_device_scan("device_scan_start", reason=reason, timeout_sec=SCAN_TIMEOUT_SEC)
        self._thread = threading.Thread(
            target=self._run_scan,
            args=(reason,),
            name="iuve-device-capability-scan",
            daemon=True,
        )
        self._thread.start()

    def _finalize_scan_profile(self, profile: dict[str, Any]) -> None:
        previous = load_profile(self.profile_path)
        scanned_allowlist = profile.get("discovered_apps_allowlist")
        if isinstance(scanned_allowlist, list):
            prev_allowlist = (
                previous.get("discovered_apps_allowlist")
                if isinstance(previous, dict)
                else None
            )
            merged, newly_discovered = merge_discovered_allowlist(prev_allowlist, scanned_allowlist)
            profile["discovered_apps_allowlist"] = merged
            profile["allowlist_count"] = len(merged)
            if newly_discovered and self.on_apps_discovered is not None:
                try:
                    self.on_apps_discovered(newly_discovered)
                except Exception:
                    pass
        save_profile(profile, self.profile_path)
        if self.on_profile_updated is not None:
            _dispatch_on_main_loop(self.on_profile_updated, profile)

    def _run_scan(self, reason: str) -> None:
        profile: dict[str, Any] | None = None
        pending_force = False
        pending_reason = ""
        started_at = time.monotonic()
        try:
            deadline = time.monotonic() + SCAN_TIMEOUT_SEC
            result_holder: list[dict[str, Any]] = []
            error_holder: list[BaseException] = []

            def _worker() -> None:
                try:
                    result_holder.append(
                        scan_device_capability_profile(scan_reason=reason, deadline=deadline)
                    )
                except BaseException as exc:
                    error_holder.append(exc)

            worker = threading.Thread(
                target=_worker,
                name="iuve-device-capability-scan-work",
                daemon=True,
            )
            worker.start()
            worker.join(timeout=SCAN_TIMEOUT_SEC + 1)

            if worker.is_alive():
                partial = result_holder[0] if result_holder else None
                profile = _profile_on_scan_failure(
                    scan_reason=reason,
                    error=f"scan_timeout_{SCAN_TIMEOUT_SEC}s",
                    profile_path=self.profile_path,
                    partial=partial,
                )
            elif error_holder:
                profile = _profile_on_scan_failure(
                    scan_reason=reason,
                    error=str(error_holder[0]),
                    profile_path=self.profile_path,
                )
            else:
                profile = result_holder[0]
                if profile.get("scan_partial"):
                    existing = load_profile(self.profile_path) or {}
                    if existing and not profile.get("scanned_at"):
                        profile["scanned_at"] = existing.get("scanned_at") or _utc_now_iso()
        except Exception as exc:
            profile = _profile_on_scan_failure(
                scan_reason=reason,
                error=str(exc),
                profile_path=self.profile_path,
            )
        finally:
            with self._lock:
                self._scanning = False
                self._thread = None
                if self._pending_force or self._pending_reason:
                    pending_force = self._pending_force
                    pending_reason = self._pending_reason
                    self._pending_force = False
                    self._pending_reason = ""

        if profile is not None:
            duration_ms = int((time.monotonic() - started_at) * 1000)
            scan_error = str(profile.get("scan_error") or "").strip()
            apps_count = int(profile.get("applications_count") or 0)
            allowlist = profile.get("discovered_apps_allowlist")
            discovered_desktop_ids: list[str] = []
            if isinstance(allowlist, list):
                discovered_desktop_ids = sorted(
                    {
                        str(entry.get("desktop_id") or "").strip()
                        for entry in allowlist
                        if isinstance(entry, dict) and str(entry.get("desktop_id") or "").strip()
                    }
                )
            if scan_error.startswith("scan_timeout_"):
                _log_device_scan(
                    "device_scan_timeout",
                    reason=reason,
                    error=scan_error,
                    applications_count=apps_count,
                    duration_ms=duration_ms,
                    scan_partial=bool(profile.get("scan_partial")),
                )
            else:
                _log_device_scan(
                    "device_scan_complete",
                    reason=reason,
                    applications_count=apps_count,
                    duration_ms=duration_ms,
                    scan_error=scan_error or None,
                    scan_partial=bool(profile.get("scan_partial")),
                    discovered_desktop_ids=discovered_desktop_ids,
                    allowlist_count=int(profile.get("allowlist_count") or len(discovered_desktop_ids)),
                )
            try:
                self._finalize_scan_profile(profile)
            except Exception:
                pass

        if pending_force or pending_reason:
            self.schedule_if_needed(force=pending_force, reason=pending_reason)
