#!/usr/bin/env python3
import base64
import csv
import hashlib
import json
import os
import re
import secrets
import shlex
import ssl
import socket
import subprocess
import sys
import tempfile
import threading
import time
import traceback
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlencode
from urllib.request import Request, build_opener, ProxyHandler

import requests


def env_bool(name: str, default: bool) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}


def env_int(name: str, default: int) -> int:
    raw = os.getenv(name)
    if raw is None:
        return default
    try:
        return int(raw.strip())
    except ValueError:
        return default


def mask_proxy_url(url: str) -> str:
    if "@" in url and "://" in url:
        scheme, rest = url.split("://", 1)
        auth_host = rest.split("@", 1)
        if len(auth_host) == 2:
            return f"{scheme}://***:***@{auth_host[1]}"
    return url


@dataclass
class PendingIncident:
    incident_id: str
    created_ts: int
    failures: List[str]
    suggestions: List[str]
    last_autoheal_ts: int
    autoheal_attempts: int
    operator_acked: bool
    escalated_to_ai: bool
    fallback_executed: bool


@dataclass
class PendingPfSenseChange:
    request_id: str
    created_ts: int
    operator_request: str
    stage: str
    confirm_code: str
    first_confirmed_ts: int


@dataclass
class PendingOpenVpnConfig:
    request_id: str
    created_ts: int
    common_name: str
    stage: str
    confirm_code: str
    first_confirmed_ts: int


@dataclass
class PendingProxmoxSelection:
    mode: str
    created_ts: int


@dataclass
class PendingProxmoxRestore:
    request_id: str
    created_ts: int
    kind: str
    guest_id: str
    guest_name: str
    node: str
    snapshot: str
    confirm_code: str


class GuardianState:
    def __init__(self, path: str):
        self.path = path
        self.last_update_id = 0
        self.last_operator_message_ts = 0
        self.pending_incident: Optional[PendingIncident] = None
        self.pending_pfsense_change: Optional[PendingPfSenseChange] = None
        self.pending_openvpn_config: Optional[PendingOpenVpnConfig] = None
        self.pending_proxmox_selection: Optional[PendingProxmoxSelection] = None
        self.pending_proxmox_restore: Optional[PendingProxmoxRestore] = None
        self.pending_update_install_confirm = False
        self.pending_rollback_confirm = False
        self.last_warning_signature = ""
        self.failure_streak_signature = ""
        self.failure_streak_count = 0
        self.failure_streak_first_ts = 0
        self.last_openvpn_expiry_signature = ""
        self.ai_chat_intro_variant = -1

    def load(self) -> None:
        if not os.path.exists(self.path):
            return
        with open(self.path, "r", encoding="utf-8") as f:
            raw = json.load(f)
        self.last_update_id = int(raw.get("last_update_id", 0))
        self.last_operator_message_ts = int(raw.get("last_operator_message_ts", 0))
        self.pending_update_install_confirm = bool(raw.get("pending_update_install_confirm", False))
        self.pending_rollback_confirm = bool(raw.get("pending_rollback_confirm", False))
        self.last_warning_signature = str(raw.get("last_warning_signature", ""))
        self.failure_streak_signature = str(raw.get("failure_streak_signature", ""))
        self.failure_streak_count = int(raw.get("failure_streak_count", 0))
        self.failure_streak_first_ts = int(raw.get("failure_streak_first_ts", 0))
        self.last_openvpn_expiry_signature = str(raw.get("last_openvpn_expiry_signature", ""))
        self.ai_chat_intro_variant = int(raw.get("ai_chat_intro_variant", -1))
        pi = raw.get("pending_incident")
        if pi:
            self.pending_incident = PendingIncident(**pi)
        ppc = raw.get("pending_pfsense_change")
        if ppc:
            self.pending_pfsense_change = PendingPfSenseChange(**ppc)
        povpn = raw.get("pending_openvpn_config")
        if povpn:
            self.pending_openvpn_config = PendingOpenVpnConfig(**povpn)
        pps = raw.get("pending_proxmox_selection")
        if pps:
            self.pending_proxmox_selection = PendingProxmoxSelection(**pps)
        ppr = raw.get("pending_proxmox_restore")
        if ppr:
            self.pending_proxmox_restore = PendingProxmoxRestore(**ppr)

    def save(self) -> None:
        os.makedirs(os.path.dirname(self.path), exist_ok=True)
        payload = {
            "last_update_id": self.last_update_id,
            "last_operator_message_ts": self.last_operator_message_ts,
            "pending_incident": asdict(self.pending_incident) if self.pending_incident else None,
            "pending_pfsense_change": asdict(self.pending_pfsense_change) if self.pending_pfsense_change else None,
            "pending_openvpn_config": asdict(self.pending_openvpn_config) if self.pending_openvpn_config else None,
            "pending_proxmox_selection": asdict(self.pending_proxmox_selection) if self.pending_proxmox_selection else None,
            "pending_proxmox_restore": asdict(self.pending_proxmox_restore) if self.pending_proxmox_restore else None,
            "pending_update_install_confirm": self.pending_update_install_confirm,
            "pending_rollback_confirm": self.pending_rollback_confirm,
            "last_warning_signature": self.last_warning_signature,
            "failure_streak_signature": self.failure_streak_signature,
            "failure_streak_count": self.failure_streak_count,
            "failure_streak_first_ts": self.failure_streak_first_ts,
            "last_openvpn_expiry_signature": self.last_openvpn_expiry_signature,
            "ai_chat_intro_variant": self.ai_chat_intro_variant,
        }
        tmp = self.path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False, indent=2)
        os.replace(tmp, self.path)


class TelegramAPI:
    def __init__(self, token: str, timeout_sec: int = 20, proxy_url: str = ""):
        self.base = f"https://api.telegram.org/bot{token}"
        self.timeout_sec = timeout_sec
        self.proxy_url = proxy_url.strip()
        if self.proxy_url:
            self.opener = build_opener(
                ProxyHandler({"http": self.proxy_url, "https": self.proxy_url})
            )
        else:
            self.opener = build_opener()

    def _should_retry(self, exc: Exception) -> bool:
        text = str(exc).lower()
        return any(
            marker in text
            for marker in (
                "503 service unavailable",
                "tunnel connection failed",
                "connection refused",
                "connection reset",
                "timed out",
                "temporary failure",
                "name or service not known",
                "network is unreachable",
            )
        )

    def _call(self, method: str, params: Dict) -> Dict:
        url = f"{self.base}/{method}"
        data = urlencode(params).encode("utf-8")
        req = Request(url, data=data, method="POST")
        last_exc: Optional[Exception] = None
        attempts = 6 if self.proxy_url else 3
        for attempt in range(1, attempts + 1):
            try:
                with self.opener.open(req, timeout=self.timeout_sec + 5) as resp:
                    body = resp.read().decode("utf-8")
                break
            except Exception as exc:
                last_exc = exc
                if attempt >= attempts or not self._should_retry(exc):
                    raise
                print(
                    f"{time.strftime('%Y-%m-%d %H:%M:%S')} [WARN] Telegram API {method} attempt {attempt}/{attempts} failed: {exc}",
                    flush=True,
                )
                time.sleep(min(attempt * 2, 10))
        else:
            raise last_exc if last_exc else RuntimeError(f"Telegram API call failed: {method}")
        parsed = json.loads(body)
        if not parsed.get("ok"):
            raise RuntimeError(f"Telegram API error on {method}: {parsed}")
        return parsed

    def _requests_proxies(self) -> Optional[Dict[str, str]]:
        if not self.proxy_url:
            return None
        return {"http": self.proxy_url, "https": self.proxy_url}

    def get_updates(self, offset: int, timeout: int = 15) -> List[Dict]:
        r = self._call("getUpdates", {"offset": offset, "timeout": timeout})
        return r.get("result", [])

    def send_message(self, chat_id: int, text: str, reply_markup: Optional[Dict] = None) -> None:
        payload = {"chat_id": chat_id, "text": text}
        if reply_markup is not None:
            payload["reply_markup"] = json.dumps(reply_markup, ensure_ascii=False)
        self._call("sendMessage", payload)

    def send_document(self, chat_id: int, filename: str, content: bytes, caption: str = "") -> None:
        if not filename.strip():
            raise RuntimeError("sendDocument requires a non-empty filename")
        if not content:
            raise RuntimeError(f"Refusing to send empty document: {filename}")

        data = {"chat_id": str(chat_id)}
        if caption:
            data["caption"] = caption
        files = {"document": (filename, content, "application/octet-stream")}

        last_exc: Optional[Exception] = None
        attempts = 6 if self.proxy_url else 3
        for attempt in range(1, attempts + 1):
            try:
                resp = requests.post(
                    f"{self.base}/sendDocument",
                    data=data,
                    files=files,
                    proxies=self._requests_proxies(),
                    timeout=self.timeout_sec + 10,
                )
                resp.raise_for_status()
                parsed = resp.json()
                if not parsed.get("ok"):
                    raise RuntimeError(f"Telegram API error on sendDocument: {parsed}")
                return
            except Exception as exc:
                last_exc = exc
                if attempt >= attempts or not self._should_retry(exc):
                    raise
                print(
                    f"{time.strftime('%Y-%m-%d %H:%M:%S')} [WARN] Telegram API sendDocument attempt {attempt}/{attempts} failed: {exc}",
                    flush=True,
                )
                time.sleep(min(attempt * 2, 10))
        raise last_exc if last_exc else RuntimeError("Telegram API sendDocument failed")

    def send_long_message(
        self,
        chat_id: int,
        text: str,
        reply_markup: Optional[Dict] = None,
        limit: int = 3500,
    ) -> None:
        chunks: List[str] = []
        remaining = (text or "").strip()
        if not remaining:
            self.send_message(chat_id, "-", reply_markup=reply_markup)
            return

        while len(remaining) > limit:
            split_at = remaining.rfind("\n", 0, limit)
            if split_at <= 0:
                split_at = limit
            chunks.append(remaining[:split_at].strip())
            remaining = remaining[split_at:].strip()
        if remaining:
            chunks.append(remaining)

        for idx, chunk in enumerate(chunks):
            self.send_message(chat_id, chunk, reply_markup=reply_markup if idx == 0 else None)


class TSJGuardianBot:
    BTN_STATUS = "Статус"
    BTN_CHECK = "Диагностика"
    BTN_HEAL = "Лечение"
    BTN_ACK = "Подтвердить инцидент"
    BTN_RESOLVE = "Закрыть инцидент"
    BTN_AI = "Эскалация в тех.поддержку"
    BTN_FALLBACK = "Server Fallback"
    BTN_HELP = "Помощь"
    BTN_UPD_CHECK = "Проверить критичные и важные обновления"
    BTN_UPD_INSTALL = "Установить критичные и важные обновления"
    BTN_UPD_INSTALL_CONFIRM = "Подтвердить установку обновлений"
    BTN_UPD_ROLLBACK_CONFIRM = "Подтвердить откат узла"
    BTN_PM_SNAPSHOT = "Создать Proxmox снапшот"
    BTN_PM_RESTORE = "Восстановить из снапшота"
    BTN_AI_CHAT = "Тех.поддержка"
    BTN_OVPN_CERTS = "OpenVPN сертификаты"
    BTN_OVPN_EXPIRING = "Истекающие OpenVPN cert"
    BTN_OVPN_CONFIG = "Новый OpenVPN конфиг"
    BTN_OVPN_CONFIG_CONFIRM = "Подтвердить OpenVPN: шаг 1"
    BTN_OVPN_CONFIG_CANCEL = "Отменить OpenVPN конфиг"
    BTN_PFSENSE_CONFIRM = "Подтвердить pfSense: шаг 1"
    BTN_PFSENSE_CANCEL = "Отменить pfSense изменение"
    BTN_AW_DLP_CHECK = "Проверка AW-Rus + DLP"
    BTN_DLP_MODE_TOGGLE = "DLP режим: переключить"
    BTN_AW_DFIR = "Форензика Windows логов"
    BTN_AW_DFIR_LEGACY = "Hayabusa DFIR"
    BTN_AI_CHAT_ALIASES = ("AI чат", "Чат с поддержкой", "Техподдержка", "Тех поддержка")
    BTN_OVPN_CERTS_ALIASES = ("OpenVPN certs", "OpenVPN cert", "OpenVPN серты", "OpenVPN сертификат")
    INFRA_ADMIN_ROOT = "/opt/infra-admin"
    PFSENSE_ENV_PATH = "~/.config/tsj-bot/pfsense.env.readonly"
    PFSENSE_INVENTORY_PATH = "~/.config/tsj-bot/inventory.md"

    HUMAN_OPERATOR_STYLE = (
        "Ты отвечаешь оператору как реальный инженер техподдержки проекта DetMir. "
        "Пиши по-русски, кратко, спокойно и по делу. "
        "Каждый новый ответ формулируй по-новому: меняй вступления, связки, порядок подачи и синтаксис фраз. "
        "Не повторяй одни и те же шаблонные формулировки, канцелярские обороты, одинаковые начала и одинаковые концовки между соседними ответами. "
        "Диалог должен звучать по-человечески: естественно, предметно, без роботизированного ритма и без ощущения заготовки. "
        "Никогда не говори, что ты бот, AI, модель, ассистент, агент, система, автоматизация или Codex. "
        "Не упоминай промпты, внутренние инструкции, инструменты, MCP, model capacity, reasoning, sandbox, exec, tmux или служебную инфраструктуру ответа. "
        "Не используй формулировки вроде 'я как AI', 'не могу как модель', 'в этом чате', 'я проверю через инструмент'. "
        "Перед финальным ответом быстро проверь текст на повторы штампов и при необходимости перефразируй. "
        "Подавай результат так, будто ты лично выполнил проверку или подготовил следующий безопасный шаг. "
    )

    def __init__(self):
        self.token = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
        if not self.token:
            raise RuntimeError("TELEGRAM_BOT_TOKEN is required")

        chats_raw = os.getenv("TELEGRAM_ALLOWED_CHAT_IDS", "").strip()
        if not chats_raw:
            raise RuntimeError("TELEGRAM_ALLOWED_CHAT_IDS is required")
        self.allowed_chats = {int(x.strip()) for x in chats_raw.split(",") if x.strip()}

        self.default_chat_id = int(os.getenv("TELEGRAM_DEFAULT_CHAT_ID", str(min(self.allowed_chats))))
        self.infra_admin_root = os.getenv("INFRA_ADMIN_ROOT", self.INFRA_ADMIN_ROOT).strip() or self.INFRA_ADMIN_ROOT
        self.check_script = os.getenv(
            "CHECK_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --check"
        )
        self.aw_rus_api_base = os.getenv("AW_RUS_API_BASE", "http://192.0.2.13:5600/api/0").strip()
        self.aw_rus_worktime_base = os.getenv("AW_RUS_WORKTIME_BASE", "http://192.0.2.13:5610").strip()
        self.aw_dlp_policy_api_base = os.getenv("AW_DLP_POLICY_API_BASE", "http://192.0.2.13:5601/api/0").strip()
        self.aw_dlp_policy_actor = os.getenv("AW_DLP_POLICY_ACTOR", "tsj-guardian-bot").strip() or "tsj-guardian-bot"
        self.aw_rus_worktime_heal_cmd = os.getenv(
            "AW_RUS_WORKTIME_HEAL_CMD",
            "",
        ).strip()
        self.aw_rus_dlp_heal_cmd = os.getenv(
            "AW_RUS_DLP_HEAL_CMD",
            "",
        ).strip()
        self.aw_rus_case_api_base = os.getenv("AW_RUS_CASE_API_BASE", "http://192.0.2.13:5602").strip()
        self.aw_rus_hayabusa_enabled = env_bool("AW_RUS_HAYABUSA_ENABLED", True)
        self.aw_rus_hayabusa_ssh_cmd = os.getenv(
            "AW_RUS_HAYABUSA_SSH_CMD",
            "",
        ).strip()
        self.aw_rus_host = os.getenv("AW_RUS_HOST", "HOST-EXAMPLE").strip()
        self.aw_rus_primary_user = os.getenv("AW_RUS_PRIMARY_USER", "USER1").strip()
        self.aw_rus_stale_sec = max(60, env_int("AW_RUS_STALE_SEC", 900))
        self.aw_rus_slo_enabled = env_bool("AW_RUS_SLO_ENABLED", True)
        self.aw_rus_slo_alert_window = os.getenv("AW_RUS_SLO_ALERT_WINDOW", "24h").strip() or "24h"
        self.aw_rus_slo_min_samples = max(1, env_int("AW_RUS_SLO_MIN_SAMPLES", 4))
        self.aw_rus_slo_max_age_sec = max(15, env_int("AW_RUS_SLO_MAX_AGE_SEC", 90))
        self.aw_rus_slo_summary_cmd = os.getenv(
            "AW_RUS_SLO_SUMMARY_CMD",
            "",
        ).strip()
        self.aw_rus_windows_host = os.getenv("AW_RUS_WINDOWS_HOST", "198.51.100.18").strip() or "198.51.100.18"
        self.aw_rus_windows_ssh_user = os.getenv("AW_RUS_WINDOWS_SSH_USER", "Администратор").strip() or "Администратор"
        self.aw_rus_windows_ssh_password = os.getenv("AW_RUS_WINDOWS_SSH_PASSWORD", "").strip()
        self.aw_rus_windows_config_path = os.getenv(
            "AW_RUS_WINDOWS_CONFIG_PATH",
            r"C:\ProgramData\AWatch-rus\deployment-config.json",
        ).strip() or r"C:\ProgramData\AWatch-rus\deployment-config.json"
        self.aw_rus_windows_hardening_recovery_path = os.getenv(
            "AW_RUS_WINDOWS_HARDENING_RECOVERY_PATH",
            r"C:\Program Files\AWatch-rus\windows\hardening-recovery.ps1",
        ).strip() or r"C:\Program Files\AWatch-rus\windows\hardening-recovery.ps1"
        self.aw_rus_windows_session_collector_path = os.getenv(
            "AW_RUS_WINDOWS_SESSION_COLLECTOR_PATH",
            r"C:\ProgramData\AWatch-rus\worktime-session-collector.ps1",
        ).strip() or r"C:\ProgramData\AWatch-rus\worktime-session-collector.ps1"
        launch_tasks_raw = os.getenv(
            "AW_RUS_WINDOWS_LAUNCH_TASKS",
            "ActivityWatch Launch [HOST-EXAMPLE_Администратор];ActivityWatch Launch [HOST-EXAMPLE_user5]",
        )
        self.aw_rus_windows_launch_tasks = [item.strip() for item in launch_tasks_raw.split(";") if item.strip()]
        self.aw_rus_windows_policy_path = os.getenv(
            "AW_RUS_WINDOWS_POLICY_PATH",
            r"C:\ProgramData\AWatch-rus\dlp-policy.json",
        ).strip() or r"C:\ProgramData\AWatch-rus\dlp-policy.json"
        self.aw_rus_windows_browser_collector_path = os.getenv(
            "AW_RUS_WINDOWS_BROWSER_COLLECTOR_PATH",
            r"C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1",
        ).strip() or r"C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1"
        self.aw_rus_windows_email_collector_path = os.getenv(
            "AW_RUS_WINDOWS_EMAIL_COLLECTOR_PATH",
            r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1",
        ).strip() or r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1"
        self.heal_script = os.getenv(
            "HEAL_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --heal"
        )
        self.state_file = os.getenv(
            "STATE_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_state.json"
        )
        self.log_file = os.getenv(
            "LOG_FILE", f"{self.infra_admin_root}/logs/tsj_guardian_bot.log"
        )
        self.heartbeat_file = os.getenv(
            "HEARTBEAT_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_heartbeat"
        )
        self.check_interval = env_int("CHECK_INTERVAL_SEC", 60)
        self.incident_failure_quorum_checks = max(1, env_int("INCIDENT_FAILURE_QUORUM_CHECKS", 2))
        self.operator_timeout = env_int("OPERATOR_TIMEOUT_SEC", 900)  # 15 min
        self.retry_autoheal_sec = env_int("RETRY_AUTORECOVERY_EVERY_SEC", 300)
        self.exit_on_autoheal_success = env_bool("EXIT_ON_AUTORECOVERY_SUCCESS", True)

        self.ai_escalation_mode = os.getenv("AI_ESCALATION_MODE", "codex_exec").strip().lower() or "codex_exec"
        self.ai_exec_user = os.getenv("AI_EXEC_USER", os.getenv("TMUX_USER", "igor")).strip() or "igor"
        self.codex_model = os.getenv("CODEX_MODEL", "gpt-5.3-codex").strip() or "gpt-5.3-codex"
        fallback_models_raw = os.getenv("CODEX_FALLBACK_MODELS", "gpt-5.4-mini").strip()
        self.codex_fallback_models = [
            model.strip()
            for model in fallback_models_raw.split(",")
            if model.strip() and model.strip() != self.codex_model
        ]
        self.tmux_session = os.getenv("TMUX_SESSION", "ai")
        self.tmux_user = os.getenv("TMUX_USER", self.ai_exec_user).strip() or self.ai_exec_user
        self.tmux_create_if_missing = env_bool("TMUX_CREATE_IF_MISSING", False)
        self.tmux_start_cmd = os.getenv("TMUX_START_COMMAND", "codex")
        self.enable_ai_escalation = env_bool("ENABLE_AI_ESCALATION", True)
        self.fs_immediate_ai_on_critical = env_bool("FS_IMMEDIATE_AI_ON_CRITICAL", True)
        self.ai_chat_enabled = env_bool("AI_CHAT_ENABLED", True)
        self.ai_chat_timeout_sec = env_int("AI_CHAT_TIMEOUT_SEC", 1800)
        self.ai_chat_workdir = os.getenv("AI_CHAT_WORKDIR", "~").strip()
        self.ai_chat_sandbox = os.getenv("AI_CHAT_SANDBOX", "workspace-write").strip() or "workspace-write"
        self.openvpn_cert_check_timeout_sec = max(30, env_int("OPENVPN_CERT_CHECK_TIMEOUT_SEC", 240))
        self.openvpn_expiry_warn_timeout_sec = max(30, env_int("OPENVPN_EXPIRY_WARN_TIMEOUT_SEC", 120))
        self.pfsense_change_control_enabled = env_bool("PFSENSE_CHANGE_CONTROL_ENABLED", True)
        self.pfsense_change_confirm_ttl_sec = env_int("PFSENSE_CHANGE_CONFIRM_TTL_SEC", 900)
        self.openvpn_config_enabled = env_bool("OPENVPN_CONFIG_ENABLED", True)
        self.openvpn_config_confirm_ttl_sec = env_int("OPENVPN_CONFIG_CONFIRM_TTL_SEC", 900)
        self.openvpn_expiry_warn_enabled = env_bool("OPENVPN_EXPIRY_WARN_ENABLED", True)
        self.openvpn_expiry_warn_days = env_int("OPENVPN_EXPIRY_WARN_DAYS", 30)
        self.openvpn_expiry_warn_interval_sec = max(300, env_int("OPENVPN_EXPIRY_WARN_INTERVAL_SEC", 21600))
        self.openvpn_config_server_id = max(1, env_int("OPENVPN_CONFIG_SERVER_ID", 1))
        self.openvpn_first_host_offset = max(1, env_int("OPENVPN_CONFIG_FIRST_HOST_OFFSET", 13))
        self.openvpn_helper_path = (
            os.getenv("OPENVPN_CONFIG_HELPER_PATH", f"{self.ai_chat_workdir.rstrip('/')}/tsj-bot/pfsense_openvpn_client_export.php").strip()
        )
        self.pfsense_env_path = os.getenv("PFSENSE_ENV_PATH", self.PFSENSE_ENV_PATH).strip()
        self.pfsense_inventory_path = os.getenv("PFSENSE_INVENTORY_PATH", self.PFSENSE_INVENTORY_PATH).strip()
        self.pfsense_ssh_bin = os.getenv("PFSENSE_SSH_BIN", "/usr/bin/ssh").strip() or "/usr/bin/ssh"
        self.sshpass_bin = os.getenv("SSHPASS_BIN", "/usr/bin/sshpass").strip() or "/usr/bin/sshpass"
        self.telegram_proxy_url = (
            os.getenv("TELEGRAM_PROXY_URL", "").strip()
            or os.getenv("HTTPS_PROXY", "").strip()
            or os.getenv("HTTP_PROXY", "").strip()
        )

        # If AI path is down ("my death"), server must continue autonomously.
        self.enable_server_fallback = env_bool("ENABLE_SERVER_FALLBACK", True)
        self.server_fallback_commands = [
            x.strip() for x in os.getenv(
                "SERVER_FALLBACK_COMMANDS",
                f"{self.infra_admin_root}/scripts/system_self_support.sh --heal"
            ).split(";;") if x.strip()
        ]
        self.updates_script = os.getenv(
            "UPDATES_SCRIPT",
            f"/usr/bin/python3 {self.infra_admin_root}/scripts/proxmox_lxc_critical_updates.py",
        )
        self.updates_status_file = Path(
            os.getenv(
                "UPDATES_STATUS_FILE",
                f"{self.infra_admin_root}/.state/proxmox_lxc_critical_updates.json",
            )
        )
        self.updates_rollback_file = Path(
            os.getenv(
                "UPDATES_ROLLBACK_FILE",
                f"{self.infra_admin_root}/.state/proxmox_lxc_pending_rollback.json",
            )
        )
        self.detmir_state_file = Path(os.getenv("DETMIR_AI_STATE_FILE", "/var/lib/detmir-ai/latest-state.json"))
        self.tsj_guardian_status_bin = os.getenv("TSJ_GUARDIAN_STATUS_BIN", "/usr/local/bin/tsj-guardian-status").strip()
        self.proxmox_selection_ttl_sec = env_int("PROXMOX_SELECTION_TTL_SEC", 900)
        self.proxmox_restore_confirm_ttl_sec = env_int("PROXMOX_RESTORE_CONFIRM_TTL_SEC", 900)
        self.proxmox_manual_snapshot_name = (
            os.getenv("PROXMOX_MANUAL_SNAPSHOT_NAME", "tsj-guardian-manual").strip()
            or "tsj-guardian-manual"
        )
        self.pct_bin = os.getenv("PCT_BIN", "/usr/sbin/pct").strip() or "/usr/sbin/pct"
        self.qm_bin = os.getenv("QM_BIN", "/usr/sbin/qm").strip() or "/usr/sbin/qm"
        self.pvesh_bin = os.getenv("PVESH_BIN", "/usr/bin/pvesh").strip() or "/usr/bin/pvesh"

        self.api = TelegramAPI(self.token, proxy_url=self.telegram_proxy_url)
        self.state = GuardianState(self.state_file)
        self.state.load()
        self.next_openvpn_expiry_warn_ts = time.time() + self.openvpn_expiry_warn_interval_sec
        self._openvpn_cert_check_lock = threading.Lock()
        self._openvpn_cert_check_running = False
        self._openvpn_expiry_check_lock = threading.Lock()
        self._openvpn_expiry_check_running = False
        self._check_cycle_lock = threading.Lock()
        self._check_cycle_running = False
        self._updates_action_lock = threading.Lock()
        self._updates_action_running = ""

        os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
        os.makedirs(os.path.dirname(self.heartbeat_file), exist_ok=True)
        self._log("INFO", "TSJ guardian bot initialized")
        if self.telegram_proxy_url:
            self._log("INFO", f"Telegram proxy enabled: {mask_proxy_url(self.telegram_proxy_url)}")
        else:
            self._log("WARN", "Telegram proxy is not configured")

    def _log(self, level: str, message: str) -> None:
        line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} [{level}] {message}"
        print(line, flush=True)
        with open(self.log_file, "a", encoding="utf-8") as f:
            f.write(line + "\n")

    def _touch_heartbeat(self) -> None:
        with open(self.heartbeat_file, "w", encoding="utf-8") as f:
            f.write(str(int(time.time())))

    def _notify(self, text: str) -> None:
        try:
            self.api.send_message(self.default_chat_id, text)
        except Exception as exc:
            self._log("ERROR", f"Failed to send Telegram message: {exc}")

    def _aw_dlp_toggle_button_text(self, current_mode: Optional[str] = None) -> str:
        mode = (current_mode or "").strip().lower()
        if mode not in {"monitor", "enforce", "mixed"}:
            try:
                active = self._aw_dlp_policy_request("GET", "/dlp/policies/active", timeout_sec=5)
                policy = active.get("policy") or {}
                if isinstance(policy, dict):
                    mode = self._aw_dlp_mode_from_policy(policy)
            except Exception:
                mode = ""

        if mode == "monitor":
            return "DLP сейчас: наблюдение | включить блокировку"
        if mode == "enforce":
            return "DLP сейчас: блокировка | включить наблюдение"
        if mode == "mixed":
            return "DLP сейчас: смешанный | выровнять в блокировку"
        return self.BTN_DLP_MODE_TOGGLE

    def _is_dlp_toggle_button(self, text: str) -> bool:
        normalized = (text or "").strip()
        return normalized == self.BTN_DLP_MODE_TOGGLE or normalized.startswith("DLP")

    def _is_dfir_button(self, text: str) -> bool:
        normalized = (text or "").strip()
        return normalized in {self.BTN_AW_DFIR, self.BTN_AW_DFIR_LEGACY}

    def _menu_markup(self) -> Dict:
        return {
            "keyboard": [
                [self.BTN_STATUS, self.BTN_CHECK, self.BTN_HEAL],
                [self.BTN_AW_DLP_CHECK, self._aw_dlp_toggle_button_text()],
                [self.BTN_AW_DFIR],
                [self.BTN_ACK, self.BTN_RESOLVE],
                [self.BTN_AI, self.BTN_FALLBACK],
                [self.BTN_AI_CHAT],
                [self.BTN_OVPN_CERTS, self.BTN_OVPN_EXPIRING],
                [self.BTN_OVPN_CONFIG],
                [self.BTN_OVPN_CONFIG_CONFIRM, self.BTN_OVPN_CONFIG_CANCEL],
                [self.BTN_PFSENSE_CONFIRM, self.BTN_PFSENSE_CANCEL],
                [self.BTN_UPD_CHECK, self.BTN_UPD_INSTALL],
                [self.BTN_UPD_INSTALL_CONFIRM, self.BTN_UPD_ROLLBACK_CONFIRM],
                [self.BTN_PM_SNAPSHOT, self.BTN_PM_RESTORE],
                [self.BTN_HELP],
            ],
            "resize_keyboard": True,
            "one_time_keyboard": False,
        }

    def _send_menu(self, chat_id: int, text: str) -> None:
        self.api.send_long_message(chat_id, text, reply_markup=self._menu_markup())

    def _send_text(self, chat_id: int, text: str, reply_markup: Optional[Dict] = None) -> None:
        self.api.send_long_message(chat_id, text, reply_markup=reply_markup)

    def _send_text_with_menu(self, chat_id: int, text: str) -> None:
        self._send_text(chat_id, text, reply_markup=self._menu_markup())

    def _local_time_text(self) -> str:
        return datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z")

    def _run_check_script_once(self, timeout_sec: int = 240) -> Tuple[Optional[int], str, bool]:
        with self._check_cycle_lock:
            if self._check_cycle_running:
                return None, "check already running", False
            self._check_cycle_running = True

        try:
            rc, out = self._run_shell(self.check_script, timeout_sec=timeout_sec)
            return rc, out, True
        finally:
            with self._check_cycle_lock:
                self._check_cycle_running = False

    def _next_ai_chat_intro_text(self) -> str:
        variants = [
            "На связи. Напишите сообщение обычным текстом, разберу вопрос и отвечу здесь.",
            "Можно писать прямо сюда без команд. Опишите проблему своими словами, дальше подхвачу.",
            "Диалог открыт. Отправьте следующий вопрос текстом, продолжим здесь же.",
            "Готов продолжать в этом чате. Просто напишите, что именно нужно проверить или поправить.",
            "Пишите сразу по сути. Сообщение можно отправить обычным текстом, отвечу в этой переписке.",
            "Связь открыта. Опишите задачу как есть, дальше разберу и дам ответ здесь.",
            "Продолжаем здесь. Напишите проблему или вопрос обычным сообщением, подключусь по месту.",
            "Можно без дополнительных команд. Просто отправьте сообщение, и я отвечу по ситуации.",
        ]
        next_idx = (self.state.ai_chat_intro_variant + 1) % len(variants)
        self.state.ai_chat_intro_variant = next_idx
        self.state.save()
        return variants[next_idx]

    def _updates_action_description(self, action: str) -> Tuple[str, str]:
        if action == "updates-check":
            return ("проверку критичных и важных обновлений", "до 10-15 минут")
        if action == "updates-install-confirm":
            return ("установку критичных и важных обновлений", "до 30-90 минут")
        if action == "updates-rollback-confirm":
            return ("откат после неуспешного обновления", "до 15-30 минут")
        return ("операцию обновлений", "несколько минут")

    def _updates_progress_text(self, action: str, started_at_ts: float) -> str:
        description, _ = self._updates_action_description(action)
        elapsed_sec = max(1, int(time.time() - started_at_ts))
        elapsed_min = elapsed_sec // 60
        parts = [
            f"Проверка всё ещё выполняется: {description}.",
            f"Прошло примерно {elapsed_min} мин." if elapsed_min else "Прошло меньше минуты.",
        ]
        if action == "updates-check":
            summary = self._updates_summary_text()
            if summary and "Нет данных проверки" not in summary:
                parts.append("Последний сохранённый результат:")
                parts.append(summary)
        return "\n".join(parts)

    def _start_updates_action_async(self, chat_id: int, action: str) -> None:
        action = action.strip().lower()
        with self._updates_action_lock:
            if self._updates_action_running:
                current_desc, _ = self._updates_action_description(self._updates_action_running)
                self._send_text(
                    chat_id,
                    "Операция обновлений уже выполняется.\n"
                    f"Сейчас идёт {current_desc}. Дождитесь результата.",
                )
                return
            self._updates_action_running = action

        description, eta = self._updates_action_description(action)
        started_at = self._local_time_text()
        started_at_ts = time.time()
        ack_text = (
            "Запрос принят.\n"
            f"Старт: {started_at}\n"
            f"Начинаю {description}. Это может занять {eta}.\n"
            "Результат пришлю отдельным сообщением."
        )
        if action == "updates-check":
            summary = self._updates_summary_text()
            if summary and "Нет данных проверки" not in summary:
                ack_text += f"\n\nПоследний сохранённый результат:\n{summary}"
        try:
            self._send_text(chat_id, ack_text)
        except Exception as exc:
            self._log("ERROR", f"Failed to deliver async updates ack ({action}): {exc}")
        self._log("ACTION", f"Starting async updates action={action} chat_id={chat_id}")

        def progress_notifier() -> None:
            time.sleep(90)
            with self._updates_action_lock:
                still_running = self._updates_action_running == action
            if not still_running:
                return
            try:
                self._send_text(chat_id, self._updates_progress_text(action, started_at_ts))
            except Exception as exc:
                self._log("ERROR", f"Failed to deliver async updates progress ({action}): {exc}")

        def worker() -> None:
            try:
                result = self._run_operator_action(action)
                finished_at = self._local_time_text()
                self._log("INFO", f"Updates action completed ({action}): {result[:1200]}")
                self._send_text(chat_id, f"Завершено: {finished_at}\n{result}")
            except Exception as exc:
                self._log("ERROR", f"Updates action handler failed ({action}): {exc}\n{traceback.format_exc()}")
                if isinstance(exc, subprocess.TimeoutExpired):
                    msg = f"Операция обновлений превысила лимит времени: {exc.timeout} сек."
                else:
                    msg = f"Не удалось выполнить операцию обновлений ({action}): {exc}"
                self._log("ERROR", f"Updates action delivery/result error ({action}): {msg}")
                self._send_text(chat_id, msg)
            finally:
                with self._updates_action_lock:
                    self._updates_action_running = ""

        threading.Thread(target=progress_notifier, name=f"updates-progress-{action}", daemon=True).start()
        threading.Thread(target=worker, name=f"updates-{action}", daemon=True).start()

    def _start_openvpn_cert_check_async(self, chat_id: int, search_term: str = "") -> None:
        with self._openvpn_cert_check_lock:
            if self._openvpn_cert_check_running:
                self._send_text(
                    chat_id,
                    "Проверка OpenVPN сертификатов уже выполняется. Дождитесь текущего результата.",
                )
                return
            self._openvpn_cert_check_running = True

        self._send_text(chat_id, "Запрос принят. Проверяю OpenVPN сертификаты, это может занять до 3-4 минут.")
        self._log("ACTION", f"Starting async OpenVPN cert check (chat_id={chat_id}, filter={search_term!r})")

        def worker() -> None:
            try:
                result = self._run_openvpn_cert_check_codex_exec(search_term)
                self._send_text(chat_id, result)
            except Exception as exc:
                self._log("ERROR", f"OpenVPN cert check handler failed: {exc}")
                if isinstance(exc, subprocess.TimeoutExpired):
                    msg = f"Проверка OpenVPN сертификатов превысила лимит {self.openvpn_cert_check_timeout_sec} сек."
                else:
                    msg = f"Не удалось выполнить проверку OpenVPN сертификатов: {exc}"
                self._send_text(chat_id, msg)
            finally:
                with self._openvpn_cert_check_lock:
                    self._openvpn_cert_check_running = False

        threading.Thread(target=worker, name="ovpn-cert-check", daemon=True).start()

    def _start_openvpn_expiry_check_async(self, chat_id: int) -> None:
        with self._openvpn_expiry_check_lock:
            if self._openvpn_expiry_check_running:
                self._send_text(
                    chat_id,
                    "Проверка истекающих OpenVPN сертификатов уже выполняется. Дождитесь текущего результата.",
                )
                return
            self._openvpn_expiry_check_running = True

        self._send_text(chat_id, "Запрос принят. Проверяю истекающие OpenVPN сертификаты, это может занять до 1-2 минут.")
        self._log("ACTION", f"Starting async OpenVPN expiry check (chat_id={chat_id})")

        def worker() -> None:
            try:
                _, report = self._run_openvpn_expiry_codex_exec(self.openvpn_expiry_warn_days, expiring_only=True)
                self._send_text(chat_id, report)
            except Exception as exc:
                self._log("ERROR", f"OpenVPN expiry check handler failed: {exc}")
                if isinstance(exc, subprocess.TimeoutExpired):
                    msg = (
                        "Проверка истекающих OpenVPN сертификатов превысила лимит "
                        f"{self.openvpn_expiry_warn_timeout_sec} сек."
                    )
                else:
                    msg = f"Не удалось получить список истекающих OpenVPN сертификатов: {exc}"
                self._send_text(chat_id, msg)
            finally:
                with self._openvpn_expiry_check_lock:
                    self._openvpn_expiry_check_running = False

        threading.Thread(target=worker, name="ovpn-expiry-check", daemon=True).start()

    @staticmethod
    def _normalize_button_text(text: str) -> str:
        return re.sub(r"\s+", " ", (text or "").strip().lower())

    def _button_matches(self, text: str, primary: str, aliases: Tuple[str, ...] = ()) -> bool:
        normalized = self._normalize_button_text(text)
        candidates = [primary, *aliases]
        return any(normalized == self._normalize_button_text(candidate) for candidate in candidates)

    def _run_shell(self, cmd: str, timeout_sec: int = 180) -> Tuple[int, str]:
        p = subprocess.run(
            ["bash", "-lc", cmd],
            text=True,
            encoding="utf-8",
            errors="replace",
            capture_output=True,
            timeout=timeout_sec,
            check=False,
        )
        out = (p.stdout or "") + (("\n" + p.stderr) if p.stderr else "")
        return p.returncode, out.strip()

    @staticmethod
    def _decode_process_output(data: Optional[bytes]) -> str:
        if not data:
            return ""
        try:
            return data.decode("utf-8")
        except UnicodeDecodeError:
            pass

        def score_text(text: str) -> int:
            score = 0
            for ch in text:
                code = ord(ch)
                if ch in "\r\n\t":
                    score += 1
                elif 32 <= code <= 126:
                    score += 1
                elif 0x0400 <= code <= 0x04FF or code == 0x2116:
                    score += 3
                else:
                    score -= 2
            return score

        candidates: List[Tuple[int, str]] = []
        for encoding in ("cp866", "cp1251"):
            try:
                decoded = data.decode(encoding)
            except UnicodeDecodeError:
                continue
            candidates.append((score_text(decoded), decoded))
        if candidates:
            candidates.sort(key=lambda item: item[0], reverse=True)
            return candidates[0][1]
        return data.decode("utf-8", errors="replace")

    @staticmethod
    def _parse_worktime_today_csv(csv_text: str) -> List[Dict[str, Optional[int]]]:
        rows: List[Dict[str, Optional[int]]] = []
        for raw_row in csv.DictReader(csv_text.splitlines()):
            user = (raw_row.get("user") or raw_row.get("username") or "").strip()
            active_raw = raw_row.get("active_seconds")
            if active_raw is None:
                values = list(raw_row.values())
                active_raw = values[1] if len(values) > 1 else None
            try:
                active_seconds = int(float((active_raw or "").strip()))
            except Exception:
                active_seconds = None
            rows.append(
                {
                    "user": user,
                    "active_seconds": active_seconds,
                }
            )
        return rows

    def _fetch_worktime_today_csv(self, timeout_sec: int = 20, attempts: int = 2) -> str:
        probe_url = f"{self.aw_rus_worktime_base.rstrip('/')}/reports/worktime/today?format=csv"
        last_exc: Optional[Exception] = None
        for attempt in range(max(1, attempts)):
            try:
                r = requests.get(probe_url, timeout=timeout_sec)
                r.raise_for_status()
                return r.text
            except Exception as exc:
                last_exc = exc
                if attempt + 1 >= max(1, attempts):
                    raise
                time.sleep(1)
        raise last_exc or RuntimeError("worktime report fetch failed")

    def _run_argv(
        self,
        argv: List[str],
        timeout_sec: int = 180,
        input_text: str = "",
        env_extra: Optional[Dict[str, str]] = None,
        cwd: Optional[str] = None,
    ) -> subprocess.CompletedProcess:
        env = os.environ.copy()
        if env_extra:
            env.update(env_extra)
        proc = subprocess.run(
            argv,
            text=False,
            input=input_text.encode("utf-8"),
            capture_output=True,
            timeout=timeout_sec,
            check=False,
            env=env,
            cwd=cwd,
        )
        return subprocess.CompletedProcess(
            proc.args,
            proc.returncode,
            self._decode_process_output(proc.stdout),
            self._decode_process_output(proc.stderr),
        )

    def _get_proxmox_guest_lock(self, kind: str, guest_id: str) -> str:
        if kind == "qemu":
            cmd = f"{shlex.quote(self.qm_bin)} config {shlex.quote(guest_id)}"
        else:
            cmd = f"{shlex.quote(self.pct_bin)} config {shlex.quote(guest_id)}"
        rc, out = self._run_shell(cmd, timeout_sec=60)
        if rc != 0:
            return ""
        for line in out.splitlines():
            stripped = line.strip()
            if stripped.startswith("lock:"):
                return stripped.split(":", 1)[1].strip()
        return ""

    def _has_live_proxmox_locking_process(self, kind: str, guest_id: str) -> bool:
        guest_pat = re.escape(str(guest_id))
        base_pat = (
            r"(pct|qm|vzdump)"
            r".*("
            r"snapshot|rollback|restore|backup|mount|delsnapshot|listsnapshot"
            r").*\b" + guest_pat + r"\b"
            r"|"
            r"(pct|qm|vzdump)"
            r".*\b" + guest_pat + r"\b.*("
            r"snapshot|rollback|restore|backup|mount|delsnapshot|listsnapshot"
            r")"
        )
        if kind == "lxc":
            base_pat += r"|lxc-usernsexec.*(/var/lib/lxc/" + guest_pat + r"/rootfs|/run/lxc/)"
        rc, out = self._run_shell(f"pgrep -af {shlex.quote(base_pat)}", timeout_sec=30)
        if rc != 0:
            return False
        lines = [line.strip() for line in out.splitlines() if line.strip()]
        return bool(lines)

    def _clear_stale_proxmox_lock_if_safe(self, kind: str, guest_id: str) -> Tuple[bool, str]:
        lock = self._get_proxmox_guest_lock(kind, guest_id)
        if not lock:
            return False, ""
        if self._has_live_proxmox_locking_process(kind, guest_id):
            return False, f"guest {kind}:{guest_id} lock={lock}, live locking process detected"
        unlock_bin = self.qm_bin if kind == "qemu" else self.pct_bin
        rc, out = self._run_shell(
            f"{shlex.quote(unlock_bin)} unlock {shlex.quote(guest_id)}",
            timeout_sec=60,
        )
        if rc != 0:
            raise RuntimeError(
                f"Не удалось снять stale lock `{lock}` с {kind}:{guest_id}: {out[-2000:]}"
            )
        self._log("WARN", f"Cleared stale Proxmox lock for {kind}:{guest_id}: {lock}")
        return True, lock

    def _run_ai_user_shell(self, cmd: str, timeout_sec: int = 180) -> Tuple[int, str]:
        if self.ai_exec_user:
            cmd = f"sudo -u {shlex.quote(self.ai_exec_user)} bash -lc {shlex.quote(cmd)}"
        return self._run_shell(cmd, timeout_sec=timeout_sec)

    def _run_ai_user_argv(
        self,
        argv: List[str],
        timeout_sec: int = 180,
        env_extra: Optional[Dict[str, str]] = None,
    ) -> subprocess.CompletedProcess:
        cmd = list(argv)
        if self.ai_exec_user:
            preserve = []
            if env_extra and env_extra.get("PFSENSE_MCP_BEARER"):
                preserve.append("PFSENSE_MCP_BEARER")
            sudo_argv = ["sudo", "-u", self.ai_exec_user]
            if preserve:
                sudo_argv.append(f"--preserve-env={','.join(preserve)}")
            cmd = [*sudo_argv, "--", *cmd]
        return self._run_argv(
            cmd,
            timeout_sec=timeout_sec,
            env_extra=env_extra,
            cwd=self.ai_chat_workdir,
        )

    def _run_codex_exec_prompt(self, prompt: str, timeout_sec: int, model: Optional[str] = None) -> Tuple[int, str, str]:
        with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as tmp:
            tmp_path = tmp.name
        os.chmod(tmp_path, 0o666)
        selected_model = (model or self.codex_model).strip() or self.codex_model

        # Do not forward PFSENSE_MCP_BEARER into sudo/codex. sudo logs
        # preserved env values, so passing the bearer here leaks it to journald.
        env_extra = None
        argv = [
            "codex",
            "exec",
            "--ephemeral",
            "--skip-git-repo-check",
            "--model",
            selected_model,
            "-C",
            self.ai_chat_workdir,
            "-s",
            self.ai_chat_sandbox,
            "--color",
            "never",
            "-o",
            tmp_path,
            prompt,
        ]
        try:
            proc = self._run_ai_user_argv(
                argv,
                timeout_sec=timeout_sec,
                env_extra=env_extra,
            )
            rc = proc.returncode
            out = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
            try:
                with open(tmp_path, "r", encoding="utf-8") as f:
                    reply = f.read().strip()
            except FileNotFoundError:
                reply = ""
            return rc, out, reply
        finally:
            try:
                os.unlink(tmp_path)
            except FileNotFoundError:
                pass

    @staticmethod
    def _is_model_capacity_error(output: str) -> bool:
        lowered = (output or "").lower()
        return "selected model is at capacity" in lowered

    def _sanitize_operator_reply(self, text: str) -> str:
        cleaned = (text or "").strip()
        if not cleaned:
            return cleaned

        replacements = (
            (r"\bAI\b", ""),
            (r"\bCodex\b", ""),
            (r"\bMCP\b", ""),
            (r"\bTelegram-бот[аеуыом]*\b", "поддержке"),
            (r"\bбот[аеуыом]*\b", ""),
            (r"\bассистент[а-я]*\b", "инженер"),
            (r"\bмодель[а-я]*\b", ""),
            (r"\bавтоматизац[а-я]*\b", ""),
            (r"\bнейросет[а-я]*\b", ""),
            (r"\bискусственн(?:ый|ого|ому|ым|ом)? интеллект[а-я]*\b", ""),
            (r"\bя как AI\b", "я"),
            (r"\bя как ассистент\b", "я"),
            (r"\bв этом чате\b", "здесь"),
        )
        for pattern, replacement in replacements:
            cleaned = re.sub(pattern, replacement, cleaned, flags=re.IGNORECASE)

        cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
        cleaned = re.sub(r" ?\n ?", "\n", cleaned)
        cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
        return cleaned.strip()

    @staticmethod
    def _summarize_exec_error(output: str, rc: int) -> str:
        lowered = (output or "").lower()
        if TSJGuardianBot._is_codex_auth_error(output):
            return "Канал расширенной диагностики недоступен: требуется повторная авторизация Codex на сервере."
        if "403 forbidden" in lowered or "unable to load site" in lowered:
            return "Сервис ответов временно недоступен. Повторите запрос чуть позже."
        if "selected model is at capacity" in lowered:
            return "Сервис ответов перегружен. Повторите запрос чуть позже."
        if "transport channel closed" in lowered or "unexpectedcontenttype" in lowered:
            return "Сервис ответов временно недоступен из-за сетевой ошибки. Повторите запрос чуть позже."
        return f"Обработка запроса завершилась с ошибкой.\nrc={rc}"

    @staticmethod
    def _is_codex_auth_error(output: str) -> bool:
        lowered = (output or "").lower()
        return any(
            marker in lowered
            for marker in (
                "401 unauthorized",
                "token_invalidated",
                "refresh_token_reused",
                "access token could not be refreshed",
                "authentication token has been invalidated",
            )
        )

    def _run_ai_chat_codex_exec(self, operator_text: str) -> str:
        clean_text = operator_text.strip()
        if not clean_text:
            return "Пустой запрос."

        prompt = (
            f"{self.HUMAN_OPERATOR_STYLE}"
            "При работе с pfSense используй локальный MCP server `pfsense-enhanced`, если он доступен. "
            "pfSense разрешено использовать только в режиме чтения по умолчанию. "
            "Никогда не выполняй pfSense write-операции в этом пути: firewall rules, aliases, NAT, interfaces, routes, VPN, access lists, apply/reload config. "
            "Если оператор просит такое изменение, не выполняй его и явно направь в double-confirm flow для pfSense. "
            "Никогда не выполняй потенциально блокирующие изменения: firewall rules, NAT, interfaces, routes, VPN, access lists "
            "без прямой явной команды оператора и отдельного предупреждения о риске. "
            "Если запрос требует изменений, которые могут отрезать доступ, сначала предложи безопасный план и попроси явное подтверждение. "
            "Если можно ответить, проверить или сделать безопасную диагностику самостоятельно, делай это. "
            "Сообщение оператора:\n"
            f"{clean_text}"
        )

        self._log(
            "ACTION",
            f"Routing Telegram message to codex exec as {self.ai_exec_user} "
            f"(model={self.codex_model}): {clean_text[:200]}"
        )
        rc, out, reply = self._run_codex_exec_prompt(
            prompt,
            timeout_sec=self.ai_chat_timeout_sec,
            model=self.codex_model,
        )
        if not reply and rc != 0 and self._is_model_capacity_error(out):
            for fallback_model in self.codex_fallback_models:
                self._log(
                    "WARN",
                    f"Model {self.codex_model} is at capacity; retrying with fallback {fallback_model}"
                )
                rc, out, reply = self._run_codex_exec_prompt(
                    prompt,
                    timeout_sec=self.ai_chat_timeout_sec,
                    model=fallback_model,
                )
                if reply or rc == 0 or not self._is_model_capacity_error(out):
                    break
        if reply:
            if rc != 0:
                self._log("WARN", f"codex exec via {self.ai_exec_user} returned rc={rc} but produced final message")
            return self._sanitize_operator_reply(reply)
        if rc != 0:
            self._log("ERROR", f"codex exec via {self.ai_exec_user} failed rc={rc}: {out[-2000:]}")
            return self._summarize_exec_error(out, rc)
        return self._sanitize_operator_reply(out[-1500:]) or "Не удалось сформировать ответ."

    def _new_confirm_code(self) -> str:
        return "".join(secrets.choice("0123456789") for _ in range(6))

    def _pending_pfsense_change_expired(self) -> bool:
        ppc = self.state.pending_pfsense_change
        if not ppc:
            return False
        decision = self._confirmation_decision(
            "pfsense",
            "expire",
            ppc,
            self.pfsense_change_confirm_ttl_sec,
        )
        if isinstance(decision, dict):
            return bool(decision.get("expired"))
        return int(time.time()) - ppc.created_ts > self.pfsense_change_confirm_ttl_sec

    def _expire_pending_pfsense_change_if_needed(self) -> None:
        if self._pending_pfsense_change_expired():
            self.state.pending_pfsense_change = None
            self.state.save()

    def _looks_like_pfsense_write_request(self, text: str) -> bool:
        lowered = text.lower()
        target_terms = (
            "pfsense", "pfsense", "firewall", "фаервол", "правил", "rule", "nat",
            "порт", "port forward", "alias", "алиас", "vpn", "маршрут", "route",
            "интерфейс", "interface", "gateway", "шлюз",
        )
        action_terms = (
            "add", "create", "update", "change", "modify", "edit", "delete", "remove",
            "apply", "open", "close", "allow", "deny", "block", "unblock", "enable", "disable",
            "добав", "созда", "измени", "обнов", "удали", "примени", "разреш", "запрет",
            "открой", "закрой", "включ", "выключ", "блок", "разблок",
        )
        return any(term in lowered for term in target_terms) and any(term in lowered for term in action_terms)

    def _start_pfsense_change_flow(self, operator_text: str) -> str:
        request_id = time.strftime("%Y%m%d-%H%M%S")
        self.state.pending_pfsense_change = PendingPfSenseChange(
            request_id=request_id,
            created_ts=int(time.time()),
            operator_request=operator_text.strip(),
            stage="awaiting_first_confirm",
            confirm_code=self._new_confirm_code(),
            first_confirmed_ts=0,
        )
        self.state.save()
        return (
            "Запрос на изменение pfSense принят, но не выполнен.\n"
            f"- request_id: {request_id}\n"
            f"- запрос: {operator_text.strip()}\n"
            "- Это потенциально опасное изменение, поэтому бот не будет выполнять его сразу.\n"
            f"- Для первого подтверждения нажмите \"{self.BTN_PFSENSE_CONFIRM}\" или отправьте `/pfsense_confirm`.\n"
            f"- Для отмены нажмите \"{self.BTN_PFSENSE_CANCEL}\" или отправьте `/pfsense_cancel`."
        )

    def _confirm_pfsense_change_stage_one(self) -> str:
        self._expire_pending_pfsense_change_if_needed()
        ppc = self.state.pending_pfsense_change
        if not ppc:
            return "Нет ожидающего изменения pfSense."
        decision = self._confirmation_decision(
            "pfsense",
            "first_confirm",
            ppc,
            self.pfsense_change_confirm_ttl_sec,
        )
        if isinstance(decision, dict):
            if decision.get("clear_pending"):
                self.state.pending_pfsense_change = None
                self.state.save()
                return str(decision.get("message") or "Нет ожидающего изменения pfSense.")
            if not decision.get("allowed"):
                return str(decision.get("message") or "Первое подтверждение уже принято.")
            ppc.stage = str(decision.get("next_stage") or "awaiting_second_confirm")
            ppc.first_confirmed_ts = int(decision.get("first_confirmed_ts") or time.time())
            self.state.save()
            return (
                "Первое подтверждение принято.\n"
                f"- request_id: {ppc.request_id}\n"
                f"- запрос: {ppc.operator_request}\n"
                "- Второе подтверждение должно быть отдельным действием.\n"
                f"- Для выполнения отправьте: `/pfsense_apply {ppc.confirm_code}`\n"
                f"- Код подтверждения действует {self.pfsense_change_confirm_ttl_sec} секунд с момента создания запроса."
            )
        if ppc.stage != "awaiting_first_confirm":
            return (
                "Первое подтверждение уже принято.\n"
                f"Для второго подтверждения отправьте `/pfsense_apply {ppc.confirm_code}`."
            )

        ppc.stage = "awaiting_second_confirm"
        ppc.first_confirmed_ts = int(time.time())
        self.state.save()
        return (
            "Первое подтверждение принято.\n"
            f"- request_id: {ppc.request_id}\n"
            f"- запрос: {ppc.operator_request}\n"
            "- Второе подтверждение должно быть отдельным действием.\n"
            f"- Для выполнения отправьте: `/pfsense_apply {ppc.confirm_code}`\n"
            f"- Код подтверждения действует {self.pfsense_change_confirm_ttl_sec} секунд с момента создания запроса."
        )

    def _cancel_pfsense_change(self) -> str:
        decision = self._confirmation_decision(
            "pfsense",
            "cancel",
            self.state.pending_pfsense_change,
            self.pfsense_change_confirm_ttl_sec,
        )
        self.state.pending_pfsense_change = None
        self.state.save()
        if isinstance(decision, dict):
            return str(decision.get("message") or "Ожидающее изменение pfSense отменено.")
        return "Ожидающее изменение pfSense отменено."

    def _pending_openvpn_config_expired(self) -> bool:
        povpn = self.state.pending_openvpn_config
        if not povpn:
            return False
        decision = self._confirmation_decision(
            "openvpn",
            "expire",
            povpn,
            self.openvpn_config_confirm_ttl_sec,
        )
        if isinstance(decision, dict):
            return bool(decision.get("expired"))
        return int(time.time()) - povpn.created_ts > self.openvpn_config_confirm_ttl_sec

    def _expire_pending_openvpn_config_if_needed(self) -> None:
        if self._pending_openvpn_config_expired():
            self.state.pending_openvpn_config = None
            self.state.save()

    def _sanitize_filename(self, value: str, suffix: str) -> str:
        cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in value.strip())
        cleaned = cleaned.strip("._") or "openvpn-client"
        if not cleaned.endswith(suffix):
            cleaned += suffix
        return cleaned

    def _run_openvpn_cert_check_codex_exec(self, search_term: str = "") -> str:
        now, rows, considered = self._collect_openvpn_user_cert_rows(search_term=search_term)
        flt = search_term.strip().lower()
        rows_simple: List[Tuple[datetime, str]] = [(dt, line) for dt, line, _ in rows]
        rows_simple.sort(key=lambda x: x[0])
        lines = [x[1] for x in rows_simple]
        if not lines:
            if flt:
                return f"OpenVPN user-сертификаты по фильтру `{search_term.strip()}` не найдены."
            if considered == 0:
                return "OpenVPN user-сертификаты не найдены."
            return "OpenVPN user-сертификаты есть, но не удалось разобрать данные по срокам."

        expired_cnt = sum(1 for dt, _ in rows_simple if dt < now)
        # Count expiring in <=30 days explicitly for readable summary.
        expiring30_cnt = sum(1 for dt, _ in rows_simple if 0 <= (dt - now).days <= 30)
        header = (
            f"OpenVPN user-сертификаты: {len(lines)} шт."
            f"{f' (фильтр: {search_term.strip()})' if flt else ''}\n"
            f"- просрочено: {expired_cnt}\n"
            f"- истекает <=30 дн.: {expiring30_cnt}\n"
        )
        return header + "\n" + "\n".join(lines[:80])

    def _load_pfsense_readonly_env(self) -> Tuple[str, str, bool]:
        env_raw = Path(self.pfsense_env_path).read_text(encoding="utf-8")
        env_map: Dict[str, str] = {}
        for line in env_raw.splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            key, value = line.split("=", 1)
            env_map[key.strip()] = value.strip()

        base_url = (env_map.get("PFSENSE_URL", "") or "").strip()
        api_key = (env_map.get("PFSENSE_API_KEY", "") or "").strip()
        verify_ssl = (env_map.get("VERIFY_SSL", "false") or "").strip().lower() in {"1", "true", "yes", "on"}
        if not base_url or not api_key:
            raise RuntimeError("Не найдены PFSENSE_URL/PFSENSE_API_KEY в .env.readonly")
        return base_url, api_key, verify_ssl

    def _collect_openvpn_user_cert_rows(
        self,
        search_term: str = "",
        warn_days: Optional[int] = None,
        expiring_only: bool = False,
    ) -> Tuple[datetime, List[Tuple[datetime, str, str]], int]:
        base_url, api_key, verify_ssl = self._load_pfsense_readonly_env()

        requests.packages.urllib3.disable_warnings()
        web = requests.Session()
        web.verify = verify_ssl
        web.trust_env = False
        headers = {"X-API-Key": api_key}

        certs_resp = web.get(f"{base_url}/api/v2/system/certificates", headers=headers, timeout=25)
        certs_resp.raise_for_status()
        certs = certs_resp.json().get("data") or []

        ovpn_resp = web.get(f"{base_url}/api/v2/vpn/openvpn/servers", headers=headers, timeout=25)
        ovpn_resp.raise_for_status()
        ovpn_servers = ovpn_resp.json().get("data") or []
        ovpn_carefs = {str(x.get("caref", "")) for x in ovpn_servers if x.get("caref")}

        now = datetime.now(timezone.utc)
        flt = search_term.strip().lower()
        limit_days = 30 if warn_days is None else max(0, int(warn_days))
        rows: List[Tuple[datetime, str, str]] = []
        considered = 0

        for cert in certs:
            cert_type = str(cert.get("type", "")).strip().lower()
            caref = str(cert.get("caref", "")).strip()
            descr = str(cert.get("descr", "")).strip()
            refid = str(cert.get("refid", "")).strip()
            crt = str(cert.get("crt", "")).strip()
            if cert_type != "user":
                continue
            if ovpn_carefs and caref not in ovpn_carefs:
                continue
            if not crt:
                continue

            with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp:
                tmp.write(crt)
                tmp_path = tmp.name
            try:
                parsed = ssl._ssl._test_decode_cert(tmp_path)
            finally:
                try:
                    os.unlink(tmp_path)
                except FileNotFoundError:
                    pass

            cn = ""
            for rdn in parsed.get("subject", []):
                for item in rdn:
                    if len(item) == 2 and item[0] == "commonName":
                        cn = item[1]
                        break
                if cn:
                    break
            not_after_raw = (parsed.get("notAfter", "") or "").strip()
            if not not_after_raw:
                continue
            not_after = datetime.strptime(not_after_raw, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
            days_left = (not_after - now).days
            considered += 1

            search_blob = " ".join([cn, descr, refid]).lower()
            if flt and flt not in search_blob:
                continue
            if expiring_only and days_left > limit_days:
                continue

            if days_left < 0:
                state = f"ПРОСРОЧЕН {-days_left} дн."
            elif days_left <= limit_days:
                state = f"истекает через {days_left} дн."
            else:
                state = f"OK, {days_left} дн."
            name = cn or descr or refid or "unknown"
            line = (
                f"- {name}: {state}, до {not_after.strftime('%Y-%m-%d %H:%M UTC')} "
                f"(descr={descr or '-'}, refid={refid or '-'})"
            )
            signature_line = "|".join(
                [
                    name,
                    descr or "-",
                    refid or "-",
                    not_after.strftime("%Y-%m-%dT%H:%M:%SZ"),
                    str(days_left),
                ]
            )
            rows.append((not_after, line, signature_line))

        return now, rows, considered

    def _run_openvpn_expiry_codex_exec(
        self,
        warn_days: int,
        expiring_only: bool = True,
        timeout_sec: Optional[int] = None,
    ) -> Tuple[str, str]:
        del timeout_sec
        _, rows, considered = self._collect_openvpn_user_cert_rows(
            warn_days=warn_days,
            expiring_only=expiring_only,
        )
        rows.sort(key=lambda x: x[0])
        report_lines = [x[1] for x in rows]
        signature_payload = "\n".join(x[2] for x in rows)
        signature = f"sha256:{hashlib.sha256(signature_payload.encode('utf-8')).hexdigest()}"

        if not report_lines:
            if considered == 0:
                report = "OpenVPN user-сертификаты не найдены."
            else:
                report = (
                    f"Просроченных и истекающих в ближайшие {warn_days} дней "
                    "OpenVPN пользовательских сертификатов не найдено."
                )
            return signature, report

        report = (
            f"Просроченные и истекающие в ближайшие {warn_days} дней "
            f"OpenVPN user-сертификаты: {len(report_lines)} шт.\n\n"
            + "\n".join(report_lines[:80])
        )
        return signature, report

    def _sync_openvpn_expiry_warning(self) -> None:
        if not self.openvpn_expiry_warn_enabled:
            return
        try:
            signature, report = self._run_openvpn_expiry_codex_exec(
                self.openvpn_expiry_warn_days,
                expiring_only=True,
                timeout_sec=self.openvpn_expiry_warn_timeout_sec,
            )
        except Exception as exc:
            self._log("ERROR", f"OpenVPN expiry warning check failed: {exc}")
            return

        normalized_signature = signature or report
        if not report or "нет" in report.lower() and "истека" in report.lower():
            if self.state.last_openvpn_expiry_signature:
                self.state.last_openvpn_expiry_signature = ""
                self.state.save()
            return

        if normalized_signature == self.state.last_openvpn_expiry_signature:
            return

        self.state.last_openvpn_expiry_signature = normalized_signature
        self.state.save()
        self._notify(f"Предупреждение по OpenVPN сертификатам:\n\n{report}")

    def _start_openvpn_config_flow(self, common_name: str) -> str:
        cn = common_name.strip()
        if not cn:
            return "Укажите common name пользователя: `/openvpn_config USERNAME`."
        request_id = time.strftime("%Y%m%d-%H%M%S")
        self.state.pending_openvpn_config = PendingOpenVpnConfig(
            request_id=request_id,
            created_ts=int(time.time()),
            common_name=cn,
            stage="awaiting_first_confirm",
            confirm_code=self._new_confirm_code(),
            first_confirmed_ts=0,
        )
        self.state.save()
        return (
            "Запрос на новый OpenVPN конфиг принят, но не выполнен.\n"
            f"- request_id: {request_id}\n"
            f"- common_name: {cn}\n"
            "- Бот может сгенерировать/обновить пользовательский сертификат и экспортировать новый `.ovpn`, "
            "поэтому требуется двойное подтверждение.\n"
            f"- Для первого подтверждения нажмите \"{self.BTN_OVPN_CONFIG_CONFIRM}\" или отправьте `/openvpn_config_confirm`.\n"
            f"- Для отмены нажмите \"{self.BTN_OVPN_CONFIG_CANCEL}\" или отправьте `/openvpn_config_cancel`."
        )

    def _confirm_openvpn_config_stage_one(self) -> str:
        self._expire_pending_openvpn_config_if_needed()
        povpn = self.state.pending_openvpn_config
        if not povpn:
            return "Нет ожидающего запроса на OpenVPN конфиг."
        decision = self._confirmation_decision(
            "openvpn",
            "first_confirm",
            povpn,
            self.openvpn_config_confirm_ttl_sec,
        )
        if isinstance(decision, dict):
            if decision.get("clear_pending"):
                self.state.pending_openvpn_config = None
                self.state.save()
                return str(decision.get("message") or "Нет ожидающего запроса на OpenVPN конфиг.")
            if not decision.get("allowed"):
                return str(decision.get("message") or "Первое подтверждение уже принято.")
            povpn.stage = str(decision.get("next_stage") or "awaiting_second_confirm")
            povpn.first_confirmed_ts = int(decision.get("first_confirmed_ts") or time.time())
            self.state.save()
            return (
                "Первое подтверждение OpenVPN-конфига принято.\n"
                f"- request_id: {povpn.request_id}\n"
                f"- common_name: {povpn.common_name}\n"
                f"- Для второго подтверждения отправьте: `/openvpn_config_apply {povpn.confirm_code}`\n"
                f"- Код действует {self.openvpn_config_confirm_ttl_sec} секунд с момента создания запроса."
            )
        if povpn.stage != "awaiting_first_confirm":
            return (
                "Первое подтверждение уже принято.\n"
                f"Для второго подтверждения отправьте `/openvpn_config_apply {povpn.confirm_code}`."
            )
        povpn.stage = "awaiting_second_confirm"
        povpn.first_confirmed_ts = int(time.time())
        self.state.save()
        return (
            "Первое подтверждение OpenVPN-конфига принято.\n"
            f"- request_id: {povpn.request_id}\n"
            f"- common_name: {povpn.common_name}\n"
            f"- Для второго подтверждения отправьте: `/openvpn_config_apply {povpn.confirm_code}`\n"
            f"- Код действует {self.openvpn_config_confirm_ttl_sec} секунд с момента создания запроса."
        )

    def _cancel_openvpn_config(self) -> str:
        decision = self._confirmation_decision(
            "openvpn",
            "cancel",
            self.state.pending_openvpn_config,
            self.openvpn_config_confirm_ttl_sec,
        )
        self.state.pending_openvpn_config = None
        self.state.save()
        if isinstance(decision, dict):
            return str(decision.get("message") or "Ожидающий запрос на OpenVPN конфиг отменён.")
        return "Ожидающий запрос на OpenVPN конфиг отменён."

    def _pending_proxmox_selection_expired(self) -> bool:
        pending = self.state.pending_proxmox_selection
        if not pending:
            return False
        decision = self._confirmation_decision(
            "proxmox_selection",
            "expire",
            pending,
            self.proxmox_selection_ttl_sec,
        )
        if isinstance(decision, dict):
            return bool(decision.get("expired"))
        return int(time.time()) - pending.created_ts > self.proxmox_selection_ttl_sec

    def _expire_pending_proxmox_selection_if_needed(self) -> None:
        if self._pending_proxmox_selection_expired():
            self.state.pending_proxmox_selection = None
            self.state.save()

    def _pending_proxmox_restore_expired(self) -> bool:
        pending = self.state.pending_proxmox_restore
        if not pending:
            return False
        decision = self._confirmation_decision(
            "proxmox_restore",
            "expire",
            pending,
            self.proxmox_restore_confirm_ttl_sec,
        )
        if isinstance(decision, dict):
            return bool(decision.get("expired"))
        return int(time.time()) - pending.created_ts > self.proxmox_restore_confirm_ttl_sec

    def _expire_pending_proxmox_restore_if_needed(self) -> None:
        if self._pending_proxmox_restore_expired():
            self.state.pending_proxmox_restore = None
            self.state.save()

    def _discover_proxmox_targets(self) -> List[Dict]:
        rc, out = self._run_shell(
            f"{shlex.quote(self.pvesh_bin)} get /cluster/resources --type vm --output-format json",
            timeout_sec=60,
        )
        if rc != 0:
            raise RuntimeError(f"Не удалось получить список виртуальных узлов Proxmox: {out[-2000:]}")

        payload = json.loads(out)
        targets: List[Dict] = []
        for item in payload:
            kind = str(item.get("type", "")).strip().lower()
            guest_id = str(item.get("vmid", "")).strip()
            if kind not in {"lxc", "qemu"} or not guest_id:
                continue
            targets.append(
                {
                    "kind": kind,
                    "id": guest_id,
                    "name": str(item.get("name", "")).strip(),
                    "node": str(item.get("node", "")).strip(),
                    "status": str(item.get("status", "")).strip(),
                }
            )
        return sorted(targets, key=lambda item: (item["kind"], int(item["id"])))

    @staticmethod
    def _proxmox_target_label(target: Dict) -> str:
        kind_label = "CT" if target.get("kind") == "lxc" else "VM"
        guest_name = target.get("name") or "-"
        node = target.get("node") or "-"
        status = target.get("status") or "-"
        return f"{kind_label} {target.get('id')}: {guest_name} (node={node}, status={status})"

    def _proxmox_target_prompt(self, mode: str) -> str:
        targets = self._discover_proxmox_targets()
        if not targets:
            return "В Proxmox не найдено ни одного виртуального узла."

        action = (
            f"Для создания снапшота отправьте ID или имя нужного сервера.\n"
            f"Будет использован snapshot `{self.proxmox_manual_snapshot_name}` с заменой предыдущего."
            if mode == "snapshot"
            else f"Для восстановления отправьте ID или имя нужного сервера.\n"
                 f"Будет использован snapshot `{self.proxmox_manual_snapshot_name}`."
        )
        lines = [
            action,
            "Можно указывать в формате `200`, `lxc:200`, `qemu:100` или по имени.",
            "",
            "Доступные узлы:",
        ]
        lines.extend(f"- {self._proxmox_target_label(target)}" for target in targets[:60])
        return "\n".join(lines)

    def _resolve_proxmox_target(self, selector: str) -> Dict:
        raw = (selector or "").strip()
        if not raw:
            raise RuntimeError("Пустой идентификатор узла.")

        targets = self._discover_proxmox_targets()
        normalized = raw.lower()
        prefixed = re.match(r"^(lxc|ct|qemu|vm)\s*:\s*(\d+)$", normalized)
        if prefixed:
            kind = "lxc" if prefixed.group(1) in {"lxc", "ct"} else "qemu"
            guest_id = prefixed.group(2)
            for target in targets:
                if target["kind"] == kind and target["id"] == guest_id:
                    return target
            raise RuntimeError(f"Узел {kind}:{guest_id} не найден.")

        if normalized.isdigit():
            matches = [target for target in targets if target["id"] == normalized]
            if len(matches) == 1:
                return matches[0]
            if len(matches) > 1:
                raise RuntimeError(f"ID {normalized} неоднозначен. Укажите `lxc:{normalized}` или `qemu:{normalized}`.")

        exact_name = [target for target in targets if (target.get("name") or "").lower() == normalized]
        if len(exact_name) == 1:
            return exact_name[0]
        if len(exact_name) > 1:
            raise RuntimeError(f"Имя `{raw}` неоднозначно. Укажите ID узла.")

        partial = [target for target in targets if normalized in (target.get("name") or "").lower()]
        if len(partial) == 1:
            return partial[0]
        if len(partial) > 1:
            labels = ", ".join(self._proxmox_target_label(target) for target in partial[:5])
            raise RuntimeError(f"Найдено несколько узлов по `{raw}`: {labels}")
        raise RuntimeError(f"Узел `{raw}` не найден.")

    def _list_snapshot_names(self, kind: str, guest_id: str) -> List[str]:
        if kind == "qemu":
            rc, out = self._run_shell(
                f"{shlex.quote(self.qm_bin)} listsnapshot {shlex.quote(guest_id)}",
                timeout_sec=60,
            )
        else:
            rc, out = self._run_shell(
                f"{shlex.quote(self.pct_bin)} listsnapshot {shlex.quote(guest_id)}",
                timeout_sec=60,
            )
        if rc != 0:
            return []
        names: List[str] = []
        for line in out.splitlines():
            stripped = line.strip()
            if not stripped:
                continue
            # Support both ASCII (`->) and Unicode tree prefixes (├─, └─) in Proxmox output.
            cleaned = re.sub(r"^[`|+>\\s├└─-]+", "", stripped)
            if not cleaned:
                continue
            parts = cleaned.split()
            if not parts:
                continue
            candidate = parts[0].strip()
            if candidate and candidate.lower() not in {"name", "current", "root", "snapshot"}:
                names.append(candidate)
        return list(dict.fromkeys(names))

    def _snapshot_exists(self, kind: str, guest_id: str, snapshot: str) -> bool:
        return snapshot in self._list_snapshot_names(kind, guest_id)

    def _delete_snapshot_if_exists(self, kind: str, guest_id: str, snapshot: str) -> bool:
        if not self._snapshot_exists(kind, guest_id, snapshot):
            return False
        if kind == "qemu":
            cmd = (
                f"{shlex.quote(self.qm_bin)} delsnapshot {shlex.quote(guest_id)} "
                f"{shlex.quote(snapshot)} --force 1"
            )
        else:
            cmd = f"{shlex.quote(self.pct_bin)} delsnapshot {shlex.quote(guest_id)} {shlex.quote(snapshot)}"
        rc, out = self._run_shell(cmd, timeout_sec=300)
        if rc != 0:
            raise RuntimeError(f"Не удалось удалить предыдущий snapshot `{snapshot}`: {out[-2000:]}")
        return True

    def _create_manual_proxmox_snapshot(self, selector: str) -> str:
        target = self._resolve_proxmox_target(selector)
        snapshot = self.proxmox_manual_snapshot_name
        description = (
            "TSJ Guardian manual snapshot "
            + time.strftime("%Y-%m-%d %H:%M:%S")
        )
        cleared_lock, cleared_lock_name = self._clear_stale_proxmox_lock_if_safe(target["kind"], target["id"])
        deleted_old_snapshot = self._delete_snapshot_if_exists(target["kind"], target["id"], snapshot)
        if target["kind"] == "qemu":
            cmd = (
                f"{shlex.quote(self.qm_bin)} snapshot {shlex.quote(target['id'])} {shlex.quote(snapshot)} "
                f"--description {shlex.quote(description)} --vmstate 0"
            )
        else:
            cmd = (
                f"{shlex.quote(self.pct_bin)} snapshot {shlex.quote(target['id'])} {shlex.quote(snapshot)} "
                f"--description {shlex.quote(description)}"
            )
        rc, out = self._run_shell(cmd, timeout_sec=600)
        if rc != 0:
            lower_out = out.lower()
            if "locked" in lower_out:
                retried, retried_lock_name = self._clear_stale_proxmox_lock_if_safe(target["kind"], target["id"])
                if retried:
                    cleared_lock = True
                    cleared_lock_name = retried_lock_name or cleared_lock_name
                    rc, out = self._run_shell(cmd, timeout_sec=600)
                    lower_out = out.lower() if rc != 0 else ""
            # If proxmox still reports existing snapshot with same name, force one more delete+retry.
            if rc != 0 and ("already exists" in lower_out or "exists" in lower_out):
                deleted_old_snapshot = self._delete_snapshot_if_exists(target["kind"], target["id"], snapshot) or deleted_old_snapshot
                rc, out = self._run_shell(cmd, timeout_sec=600)
            if rc != 0:
                raise RuntimeError(f"Создание snapshot не удалось: {out[-2000:]}")
        message = (
            "Snapshot создан.\n"
            f"- узел: {self._proxmox_target_label(target)}\n"
            f"- guest_id: {target['id']} ({target['kind']})\n"
            f"- snapshot: {snapshot}\n"
            f"- deleted_old_snapshot: {'yes' if deleted_old_snapshot else 'no'}"
        )
        if cleared_lock:
            message += f"\n- stale lock `{cleared_lock_name}` был автоматически снят, так как живых mount/backup/snapshot-процессов не найдено."
        return message

    def _start_proxmox_restore_flow(self, selector: str) -> str:
        target = self._resolve_proxmox_target(selector)
        snapshot = self.proxmox_manual_snapshot_name
        if not self._snapshot_exists(target["kind"], target["id"], snapshot):
            raise RuntimeError(
                f"Для узла {self._proxmox_target_label(target)} не найден snapshot `{snapshot}`."
            )
        request_id = time.strftime("%Y%m%d-%H%M%S")
        self.state.pending_proxmox_restore = PendingProxmoxRestore(
            request_id=request_id,
            created_ts=int(time.time()),
            kind=target["kind"],
            guest_id=target["id"],
            guest_name=target.get("name", ""),
            node=target.get("node", ""),
            snapshot=snapshot,
            confirm_code=self._new_confirm_code(),
        )
        self.state.save()
        return (
            "Запрос на восстановление из snapshot принят, но ещё не выполнен.\n"
            f"- request_id: {request_id}\n"
            f"- узел: {self._proxmox_target_label(target)}\n"
            f"- snapshot: {snapshot}\n"
            "- Это опасная операция: текущее состояние узла будет заменено состоянием из snapshot.\n"
            f"- Для выполнения отправьте: `/proxmox_restore_apply {self.state.pending_proxmox_restore.confirm_code}`\n"
            "- Для отмены отправьте: `/proxmox_restore_cancel`."
        )

    def _cancel_proxmox_selection(self) -> str:
        decision = self._confirmation_decision(
            "proxmox_selection",
            "cancel",
            self.state.pending_proxmox_selection,
            self.proxmox_selection_ttl_sec,
        )
        self.state.pending_proxmox_selection = None
        self.state.save()
        if isinstance(decision, dict):
            return str(decision.get("message") or "Выбор узла Proxmox отменён.")
        return "Выбор узла Proxmox отменён."

    def _cancel_proxmox_restore(self) -> str:
        decision = self._confirmation_decision(
            "proxmox_restore",
            "cancel",
            self.state.pending_proxmox_restore,
            self.proxmox_restore_confirm_ttl_sec,
        )
        self.state.pending_proxmox_restore = None
        self.state.save()
        if isinstance(decision, dict):
            return str(decision.get("message") or "Ожидающее восстановление Proxmox отменено.")
        return "Ожидающее восстановление Proxmox отменено."

    def _apply_proxmox_restore(self, code: str) -> str:
        self._expire_pending_proxmox_restore_if_needed()
        pending = self.state.pending_proxmox_restore
        if not pending:
            return "Нет ожидающего восстановления Proxmox."
        decision = self._confirmation_decision(
            "proxmox_restore",
            "apply",
            pending,
            self.proxmox_restore_confirm_ttl_sec,
            code=code,
        )
        if isinstance(decision, dict):
            if decision.get("clear_pending"):
                self.state.pending_proxmox_restore = None
                self.state.save()
                return str(decision.get("message") or "Нет ожидающего восстановления Proxmox.")
            if not decision.get("allowed"):
                return str(decision.get("message") or "Неверный код подтверждения восстановления Proxmox.")
        elif code.strip() != pending.confirm_code:
            return "Неверный код подтверждения восстановления Proxmox."
        if not self._snapshot_exists(pending.kind, pending.guest_id, pending.snapshot):
            self.state.pending_proxmox_restore = None
            self.state.save()
            return f"Snapshot `{pending.snapshot}` больше не найден, восстановление отменено."

        if pending.kind == "qemu":
            cmd = (
                f"{shlex.quote(self.qm_bin)} rollback {shlex.quote(pending.guest_id)} "
                f"{shlex.quote(pending.snapshot)} --start 1"
            )
        else:
            cmd = (
                f"{shlex.quote(self.pct_bin)} rollback {shlex.quote(pending.guest_id)} "
                f"{shlex.quote(pending.snapshot)} --start 1"
            )
        rc, out = self._run_shell(cmd, timeout_sec=1800)
        self.state.pending_proxmox_restore = None
        self.state.save()
        if rc != 0:
            return (
                "Восстановление из snapshot завершилось с ошибкой.\n"
                f"- узел: {pending.kind}:{pending.guest_id} {pending.guest_name or ''}\n"
                f"- snapshot: {pending.snapshot}\n"
                f"{out[-1800:]}"
            )
        return (
            "Восстановление из snapshot выполнено.\n"
            f"- узел: {pending.kind}:{pending.guest_id} {pending.guest_name or ''}\n"
            f"- snapshot: {pending.snapshot}\n"
            f"{out[-1500:]}"
        )

    def _extract_tag_block(self, text: str, begin: str, end: str) -> str:
        if begin not in text or end not in text:
            return ""
        return text.split(begin, 1)[1].split(end, 1)[0].strip()

    def _extract_openvpn_filename(self, text: str) -> str:
        marker = "OVPN_FILENAME:"
        if marker not in text:
            return ""
        line = text.split(marker, 1)[1].splitlines()[0].strip()
        return self._sanitize_filename(line or "openvpn-client", ".ovpn")

    def _load_pfsense_inventory_access(self) -> Tuple[str, int, str, str]:
        text = Path(self.pfsense_inventory_path).read_text(encoding="utf-8")
        section = re.search(r"### 2\. pfSense(.*?)(?:### 3\.|\Z)", text, re.S)
        if not section:
            raise RuntimeError("pfSense section not found in inventory")
        section_text = section.group(1)
        ssh_match = re.search(r"- SSH LAN: `([^`:]+)(?::(\d+))?`", section_text)
        user_match = re.search(r"- Root login: `([^`]+)`", section_text)
        pass_match = re.search(r"- Root password: `([^`]+)`", section_text)
        if not ssh_match or not user_match or not pass_match:
            raise RuntimeError("pfSense root SSH credentials not found in inventory")
        host = ssh_match.group(1).strip()
        port = int((ssh_match.group(2) or "22").strip())
        user = user_match.group(1).strip()
        password = pass_match.group(1).strip()
        if not host or not user or not password:
            raise RuntimeError("pfSense SSH inventory values are empty")
        return host, port, user, password

    def _load_pfsense_web_access(self) -> Tuple[str, str, str]:
        text = Path(self.pfsense_inventory_path).read_text(encoding="utf-8")
        section = re.search(r"### 2\. pfSense(.*?)(?:### 3\.|\Z)", text, re.S)
        if not section:
            raise RuntimeError("pfSense section not found in inventory")
        section_text = section.group(1)
        web_match = re.search(r"- Web UI: `([^`]+)`", section_text)
        user_match = re.search(r"- Admin login: `([^`]+)`", section_text)
        pass_match = re.search(r"- Admin password: `([^`]+)`", section_text)
        if not web_match or not user_match or not pass_match:
            raise RuntimeError("pfSense web credentials not found in inventory")
        host_port = web_match.group(1).strip()
        user = user_match.group(1).strip()
        password = pass_match.group(1).strip()
        if not host_port or not user or not password:
            raise RuntimeError("pfSense web inventory values are empty")
        return f"https://{host_port}", user, password

    @staticmethod
    def _extract_pfsense_csrf_token(page: str) -> str:
        match = re.search(r'var csrfMagicToken = "([^"]+)"', page or "")
        if not match:
            raise RuntimeError("pfSense csrf token not found")
        return match.group(1)

    def _pfsense_ssh_reachable(self, host: str, port: int, timeout_sec: float = 3.0) -> bool:
        try:
            with socket.create_connection((host, port), timeout=timeout_sec):
                return True
        except OSError:
            return False

    def _parse_openvpn_helper_result(self, payload: str, common_name: str) -> Tuple[str, str, bytes]:
        data = json.loads(payload)
        filename = self._sanitize_filename(str(data.get("filename") or common_name), ".ovpn")
        config_b64 = str(data.get("config_b64") or "").strip()
        if not config_b64:
            raise RuntimeError("OpenVPN export returned empty config payload")
        try:
            config_bytes = base64.b64decode(config_b64, validate=True)
        except Exception as exc:
            raise RuntimeError(f"Invalid base64 in OpenVPN export payload: {exc}") from exc
        if not config_bytes.strip():
            raise RuntimeError("OpenVPN export returned empty decoded config")

        config_text = config_bytes.decode("utf-8", errors="replace")
        required_markers = ("client", "remote ", "<ca>")
        missing = [marker for marker in required_markers if marker not in config_text]
        if missing:
            raise RuntimeError(f"OpenVPN export does not look valid; missing markers: {', '.join(missing)}")

        summary_lines = [f"Новый OpenVPN конфиг подготовлен для {data.get('common_name') or common_name}."]
        tunnel_network = str(data.get("tunnel_network") or "").strip()
        if tunnel_network:
            summary_lines.append(f"- VPN IP: {tunnel_network}")
        summary_lines.append(f"- сертификат создан: {'да' if data.get('cert_created') else 'нет'}")
        summary_lines.append(f"- client-specific config создан: {'да' if data.get('csc_created') else 'нет'}")
        summary = "\n".join(summary_lines)
        return filename, summary, config_bytes

    def _run_openvpn_config_helper(self, povpn: PendingOpenVpnConfig) -> Tuple[str, str, bytes]:
        helper_script = Path(self.openvpn_helper_path).read_text(encoding="utf-8")
        if not helper_script.strip():
            raise RuntimeError(f"OpenVPN helper is empty: {self.openvpn_helper_path}")

        host, port, user, password = self._load_pfsense_inventory_access()
        remote_cmd = (
            "tmp=$(mktemp /tmp/tsj-ovpn-helper.XXXXXX.php) || exit 1; "
            "cat > \"$tmp\" || { rc=$?; rm -f \"$tmp\"; exit $rc; }; "
            f"php \"$tmp\" -- --common-name {shlex.quote(povpn.common_name)} "
            f"--server-id {shlex.quote(str(self.openvpn_config_server_id))} "
            f"--first-host-offset {shlex.quote(str(self.openvpn_first_host_offset))}; "
            "rc=$?; rm -f \"$tmp\"; exit $rc"
        )
        argv = [
            self.sshpass_bin,
            "-e",
            self.pfsense_ssh_bin,
            "-o",
            "PubkeyAuthentication=no",
            "-o",
            "StrictHostKeyChecking=no",
            "-p",
            str(port),
            f"{user}@{host}",
            remote_cmd,
        ]
        proc = self._run_argv(
            argv,
            timeout_sec=self.ai_chat_timeout_sec,
            input_text=helper_script,
            env_extra={"SSHPASS": password},
        )
        stdout = (proc.stdout or "").strip()
        stderr = (proc.stderr or "").strip()
        if proc.returncode != 0:
            details = stderr or stdout or f"rc={proc.returncode}"
            self._log("ERROR", f"OpenVPN helper failed rc={proc.returncode}: {details[-2000:]}")
            raise RuntimeError(f"OpenVPN helper failed: {details}")
        if not stdout:
            raise RuntimeError("OpenVPN helper returned no stdout payload")
        return self._parse_openvpn_helper_result(stdout, povpn.common_name)

    def _run_openvpn_config_web_export(self, common_name: str) -> Tuple[str, str, bytes]:
        base_url, user, password = self._load_pfsense_web_access()
        requests.packages.urllib3.disable_warnings()
        web = requests.Session()
        web.verify = False
        web.trust_env = False

        login_page = web.get(f"{base_url}/", timeout=20).text
        csrf_token = self._extract_pfsense_csrf_token(login_page)
        login_resp = web.post(
            f"{base_url}/",
            data={
                "__csrf_magic": csrf_token,
                "usernamefld": user,
                "passwordfld": password,
                "login": "Sign In",
            },
            timeout=20,
        )
        if "usernamefld" in login_resp.text:
            raise RuntimeError("pfSense web login failed")

        export_page = web.get(f"{base_url}/vpn_openvpn_export.php", timeout=20).text
        export_csrf = self._extract_pfsense_csrf_token(export_page)
        cert_pairs = re.findall(
            r"servers\[(\d+)\]\[3\]\[(\d+)\]\[0\] = '([^']*)';\s*"
            r"servers\[\1\]\[3\]\[\2\]\[1\] = '([^']*)';",
            export_page,
        )
        cert_id = ""
        for server_id, _, crtid, name in cert_pairs:
            if int(server_id) == self.openvpn_config_server_id and name == common_name:
                cert_id = crtid
                break
        if not cert_id:
            raise RuntimeError(f"OpenVPN certificate `{common_name}` is not present on pfSense export page")

        payload = {
            "__csrf_magic": export_csrf,
            "act": "confinline",
            "srvid": str(self.openvpn_config_server_id),
            "usrid": "",
            "crtid": cert_id,
            "useaddr": "serveraddr",
            "verifyservercn": "auto",
            "blockoutsidedns": "0",
            "legacy": "1",
            "silent": "0",
            "bindmode": "nobind",
            "usetoken": "0",
            "usepkcs11": "0",
            "pkcs11providers": "",
            "pkcs11id": "",
            "p12encryption": "high",
            "advancedoptions": "",
        }
        resp = web.post(f"{base_url}/vpn_openvpn_export.php", data=payload, timeout=30)
        if resp.status_code != 200:
            raise RuntimeError(f"pfSense export returned HTTP {resp.status_code}")
        config_bytes = resp.content or b""
        if not config_bytes.strip():
            raise RuntimeError("pfSense web export returned empty payload")
        config_text = config_bytes.decode("utf-8", errors="replace")
        required_markers = ("client", "remote ", "<ca>")
        missing = [marker for marker in required_markers if marker not in config_text]
        if missing:
            raise RuntimeError(f"pfSense web export does not look valid; missing markers: {', '.join(missing)}")

        disposition = resp.headers.get("content-disposition", "")
        filename_match = re.search(r'filename="?([^";]+)"?', disposition)
        filename = self._sanitize_filename(
            filename_match.group(1) if filename_match else common_name,
            ".ovpn",
        )
        summary = (
            f"OpenVPN конфиг экспортирован для {common_name}.\n"
            "- режим: pfSense web export\n"
            "- сертификат создан: нет\n"
            "- client-specific config создан: нет"
        )
        return filename, summary, config_bytes

    def _prepare_openvpn_config(self, povpn: PendingOpenVpnConfig) -> Tuple[str, str, bytes]:
        host, port, _, _ = self._load_pfsense_inventory_access()
        if self._pfsense_ssh_reachable(host, port, timeout_sec=3.0):
            try:
                return self._run_openvpn_config_helper(povpn)
            except Exception as exc:
                self._log("WARN", f"OpenVPN SSH helper failed, falling back to web export: {exc}")
        else:
            self._log("WARN", f"pfSense SSH is not reachable from bot host, using web export for {povpn.common_name}")
        return self._run_openvpn_config_web_export(povpn.common_name)

    def _apply_openvpn_config(self, chat_id: int, code: str) -> None:
        self._expire_pending_openvpn_config_if_needed()
        povpn = self.state.pending_openvpn_config
        if not povpn:
            self._send_text(chat_id, "Нет ожидающего запроса на OpenVPN конфиг.")
            return
        decision = self._confirmation_decision(
            "openvpn",
            "apply",
            povpn,
            self.openvpn_config_confirm_ttl_sec,
            code=code,
        )
        if isinstance(decision, dict):
            if decision.get("clear_pending"):
                self.state.pending_openvpn_config = None
                self.state.save()
                self._send_text(chat_id, str(decision.get("message") or "Нет ожидающего запроса на OpenVPN конфиг."))
                return
            if not decision.get("allowed"):
                self._send_text(
                    chat_id,
                    str(decision.get("message") or "Неверный код второго подтверждения OpenVPN-конфига."),
                )
                return
        else:
            if povpn.stage != "awaiting_second_confirm":
                self._send_text(chat_id, "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения.")
                return
            if code.strip() != povpn.confirm_code:
                self._send_text(chat_id, "Неверный код второго подтверждения OpenVPN-конфига.")
                return

        try:
            filename, summary, config_bytes = self._prepare_openvpn_config(povpn)
            self._log("INFO", f"Prepared OpenVPN config {filename} ({len(config_bytes)} bytes) for {povpn.common_name}")
            self.api.send_document(
                chat_id,
                filename,
                config_bytes,
                caption=summary[:900],
            )
            self._send_text(chat_id, f"OpenVPN конфиг отправлен как файл `{filename}`.")
        except Exception as exc:
            self._log("ERROR", f"Failed to deliver OpenVPN config: {exc}")
            self._send_text(chat_id, f"Не удалось подготовить или отправить OpenVPN конфиг: {exc}")
        finally:
            self.state.pending_openvpn_config = None
            self.state.save()

    def _run_pfsense_change_codex_exec(self, ppc: PendingPfSenseChange) -> str:
        prompt = (
            f"{self.HUMAN_OPERATOR_STYLE}"
            "Этот запуск имеет ОТДЕЛЬНОЕ ДВОЙНОЕ ПОДТВЕРЖДЕНИЕ оператора на выполнение одного изменения pfSense. "
            "Разрешено выполнить только тот конкретный запрос, который приведён ниже, и не более одного логически связанного изменения. "
            "Перед изменением кратко проверь текущий объект или правило. После изменения кратко проверь результат. "
            "Если локальный MCP `pfsense-enhanced` доступен только в read-only режиме, разрешено использовать прямой pfSense API/SSH доступ из локального окружения только для этого подтверждённого изменения. "
            "Никогда не выполняй дополнительные изменения сверх указанного запроса. "
            "Если запрос неоднозначен или опаснее, чем описано, не выполняй его и верни причину. "
            "Подтверждённый запрос оператора:\n"
            f"{ppc.operator_request}"
        )
        self._log("ACTION", f"Running approved pfSense change via codex exec as {self.ai_exec_user}: {ppc.request_id}")
        rc, out, reply = self._run_codex_exec_prompt(prompt, timeout_sec=self.ai_chat_timeout_sec)
        if reply:
            if rc != 0:
                self._log("WARN", f"Approved pfSense codex exec returned rc={rc} but produced final message")
            return self._sanitize_operator_reply(reply)
        self._log("ERROR", f"Approved pfSense codex exec failed rc={rc}: {out[-2000:]}")
        return self._sanitize_operator_reply(self._summarize_exec_error(out, rc))

    def _apply_pfsense_change(self, code: str) -> str:
        self._expire_pending_pfsense_change_if_needed()
        ppc = self.state.pending_pfsense_change
        if not ppc:
            return "Нет ожидающего изменения pfSense."
        decision = self._confirmation_decision(
            "pfsense",
            "apply",
            ppc,
            self.pfsense_change_confirm_ttl_sec,
            code=code,
        )
        if isinstance(decision, dict):
            if decision.get("clear_pending"):
                self.state.pending_pfsense_change = None
                self.state.save()
                return str(decision.get("message") or "Нет ожидающего изменения pfSense.")
            if not decision.get("allowed"):
                return str(decision.get("message") or "Неверный код второго подтверждения pfSense.")
        else:
            if ppc.stage != "awaiting_second_confirm":
                return "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения."
            if code.strip() != ppc.confirm_code:
                return "Неверный код второго подтверждения pfSense."

        result = self._run_pfsense_change_codex_exec(ppc)
        self.state.pending_pfsense_change = None
        self.state.save()
        return f"Результат подтверждённого изменения pfSense:\n{result}"

    def _read_json(self, path: Path) -> Dict:
        if not path.exists():
            return {}
        try:
            with path.open("r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            return {}

    def _updates_summary_text(self) -> str:
        payload = self._read_json(self.updates_status_file)
        if not payload:
            return "Нет данных проверки критичных и важных обновлений."
        lines = [
            "Проверка обновлений: "
            f"critical_total={payload.get('critical_total', 0)}, "
            f"important_total={payload.get('important_total', 0)}, "
            f"unsupported_total={payload.get('unsupported_total', 0)}",
        ]
        for node in payload.get("nodes", []):
            label = node.get("ctid") or node.get("vmid") or node.get("id")
            lines.append(
                f"- {str(node.get('kind', 'guest')).upper()} {label}: critical={len(node.get('critical', []))}, "
                f"important={len(node.get('important', []))}, "
                f"supported={node.get('supported')}, running={node.get('running')}, "
                f"error={node.get('error', '')}"
            )
        return "\n".join(lines)

    def _rollback_pending_count(self) -> int:
        payload = self._read_json(self.updates_rollback_file)
        return len(payload.get("pending_rollback", []))

    def _parse_log_level(self, output: str, level: str) -> List[str]:
        marker = f"[{level}]"
        return [line.strip() for line in output.splitlines() if marker in line]

    def _parse_failures(self, output: str) -> List[str]:
        return self._parse_log_level(output, "FAIL")

    def _parse_warnings(self, output: str) -> List[str]:
        return self._parse_log_level(output, "WARN")

    def _warning_signature(self, warnings: List[str]) -> str:
        return "\n".join(sorted(set(warnings)))

    def _failure_signature(self, failures: List[str]) -> str:
        return "\n".join(sorted(set(failures)))

    def _state_decision_payload(self) -> Dict:
        return {
            "pending_incident": asdict(self.state.pending_incident) if self.state.pending_incident else None,
            "failure_streak_signature": self.state.failure_streak_signature,
            "failure_streak_count": self.state.failure_streak_count,
            "failure_streak_first_ts": self.state.failure_streak_first_ts,
        }

    def _operator_action_state_payload(self) -> Dict:
        return {
            "pending_update_install_confirm": bool(
                getattr(self.state, "pending_update_install_confirm", False)
            ),
            "pending_rollback_confirm": bool(
                getattr(self.state, "pending_rollback_confirm", False)
            ),
            "pending_incident": asdict(self.state.pending_incident)
            if getattr(self.state, "pending_incident", None)
            else None,
        }

    def _run_status_helper_json(
        self,
        args: List[str],
        payload: Dict,
        timeout_sec: int = 10,
    ) -> Optional[Dict]:
        status_bin_raw = (getattr(self, "tsj_guardian_status_bin", "") or "").strip()
        if not status_bin_raw:
            return None
        status_bin = Path(status_bin_raw)
        if not status_bin.exists():
            return None
        try:
            proc = self._run_argv(
                [str(status_bin), *args],
                timeout_sec=timeout_sec,
                input_text=json.dumps(payload, ensure_ascii=False),
            )
            if proc.returncode != 0:
                self._log(
                    "WARN",
                    f"tsj-guardian-status decision failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}",
                )
                return None
            return json.loads(proc.stdout or "{}")
        except Exception as exc:
            self._log("WARN", f"tsj-guardian-status decision unavailable: {exc}")
            return None

    def _operator_action_decision(self, action: str) -> Optional[Dict]:
        return self._run_status_helper_json(
            ["--operator-action-decision"],
            {
                "action": action,
                "state": self._operator_action_state_payload(),
            },
        )

    def _confirmation_decision(
        self,
        kind: str,
        action: str,
        pending: object,
        ttl_seconds: int,
        code: str = "",
    ) -> Optional[Dict]:
        state = asdict(pending) if pending else {}
        return self._run_status_helper_json(
            [
                "--confirmation-decision",
                "--confirmation-ttl-seconds",
                str(ttl_seconds),
                "--now-epoch",
                str(int(time.time())),
            ],
            {
                "kind": kind,
                "action": action,
                "code": code,
                "state": state,
            },
        )

    def _autoheal_plan_decision(self, failures: List[str], slo_stale: bool = False) -> Optional[Dict]:
        return self._run_status_helper_json(
            ["--autoheal-plan-decision"],
            {
                "failures": failures,
                "slo_stale": bool(slo_stale),
            },
        )

    def _reset_failure_streak(self) -> None:
        changed = bool(
            self.state.failure_streak_signature
            or self.state.failure_streak_count
            or self.state.failure_streak_first_ts
        )
        self.state.failure_streak_signature = ""
        self.state.failure_streak_count = 0
        self.state.failure_streak_first_ts = 0
        if changed:
            self.state.save()

    def _defer_transient_new_incident(self, failures: List[str]) -> bool:
        threshold = getattr(self, "incident_failure_quorum_checks", 1)
        decision = self._run_status_helper_json(
            [
                "--incident-defer-decision",
                "--incident-failure-quorum-checks",
                str(threshold),
                "--now-epoch",
                str(int(time.time())),
            ],
            {
                "failures": failures,
                "state": self._state_decision_payload(),
            },
        )
        if isinstance(decision, dict):
            self.state.failure_streak_signature = str(decision.get("failure_streak_signature") or "")
            self.state.failure_streak_count = int(decision.get("failure_streak_count") or 0)
            self.state.failure_streak_first_ts = int(decision.get("failure_streak_first_ts") or 0)
            if decision.get("reset_failure_streak"):
                self.state.save()
                self._reset_failure_streak()
            else:
                self.state.save()
            if decision.get("defer"):
                log_line = str(decision.get("log_line") or "")
                self._log("WARN", log_line or f"Suppressing transient incident failures={len(failures)}")
                return True
            return False

        if threshold <= 1 or self._has_filesystem_critical(failures):
            return False

        now = int(time.time())
        signature = self._failure_signature(failures)
        if signature == self.state.failure_streak_signature:
            self.state.failure_streak_count += 1
        else:
            self.state.failure_streak_signature = signature
            self.state.failure_streak_count = 1
            self.state.failure_streak_first_ts = now
        self.state.save()

        if self.state.failure_streak_count < threshold:
            self._log(
                "WARN",
                "Suppressing transient incident "
                f"streak={self.state.failure_streak_count}/{threshold} "
                f"failures={len(failures)}",
            )
            return True
        return False

    def _filesystem_failures(self, failures: List[str]) -> List[str]:
        return [line for line in failures if "filesystem_usage" in line.lower()]

    def _has_filesystem_critical(self, failures: List[str]) -> bool:
        return bool(self._filesystem_failures(failures))

    def _is_aw_rus_failure_line(self, line: str) -> bool:
        return line.strip().lower().startswith("[fail] aw-rus:")

    def _extract_aw_rus_failure_keys(self, failures: List[str]) -> List[str]:
        keys: List[str] = []
        for line in failures:
            match = re.match(r"\[FAIL\]\s+aw-rus:([a-z0-9\-]+):", line.strip(), re.IGNORECASE)
            if match:
                key = match.group(1).lower()
                if key not in keys:
                    keys.append(key)
        return keys

    def _aw_rus_probe_should_run(self, failures: List[str]) -> bool:
        lowered = "\n".join(failures).lower()
        return "node_13" not in lowered

    def _aw_rus_failure_line_matches(self, line: str, key: str) -> bool:
        text = line.strip().lower()
        if not text.startswith("- "):
            return False
        if key == "worktime":
            return text.startswith("- worktime(") or text.startswith("- worktime:")
        if key == "slo":
            return text.startswith("- slo-alert:")
        return text.startswith(f"- {key}:")

    def _aw_rus_failure_log_lines(self, lines: List[str], failures: List[str]) -> List[str]:
        rendered: List[str] = []
        for key in failures:
            matching_line = next((line for line in lines if self._aw_rus_failure_line_matches(line, key)), "")
            summary = matching_line[2:].strip() if matching_line.startswith("- ") else key
            rendered.append(f"[FAIL] aw-rus:{key}: {summary}")
        return rendered

    def _load_aw_rus_slo_summary(self) -> Optional[Dict]:
        cmd = (getattr(self, "aw_rus_slo_summary_cmd", "") or "").strip()
        if not cmd:
            return None
        rc, out = self._run_shell(cmd, timeout_sec=30)
        if rc != 0:
            self._log("WARN", f"AW-Rus SLO summary command failed rc={rc}: {out[-500:]}")
            return None
        try:
            payload = json.loads(out)
        except json.JSONDecodeError as exc:
            self._log("WARN", f"AW-Rus SLO summary JSON parse failed: {exc}")
            return None
        return payload if isinstance(payload, dict) else None

    def _aw_rus_slo_lines_and_failures(self) -> Tuple[List[str], List[str]]:
        if not getattr(self, "aw_rus_slo_enabled", True):
            return [], []
        summary = self._load_aw_rus_slo_summary()
        if not summary:
            return [], []

        lines = ["Проверка AW-Rus SLO:"]
        failures: List[str] = []
        generated_raw = str(summary.get("generated_at_utc") or "")
        age = None
        try:
            generated = datetime.fromisoformat(generated_raw.replace("Z", "+00:00")).astimezone(timezone.utc)
            age = int((datetime.now(timezone.utc) - generated).total_seconds())
        except Exception:
            generated = None

        if generated is None or age is None or age > self.aw_rus_slo_max_age_sec:
            lines.append(f"- slo-summary: STALE age={age}s generated={generated_raw or 'unknown'}")
            failures.append("slo")
            return lines, failures

        windows = summary.get("windows") if isinstance(summary.get("windows"), dict) else {}
        window_name = self.aw_rus_slo_alert_window if self.aw_rus_slo_alert_window in windows else "24h"
        window = windows.get(window_name) if isinstance(windows.get(window_name), dict) else {}
        samples = int(window.get("samples") or 0)
        availability = window.get("availability_percent")
        remaining = int(window.get("budget_remaining_seconds") or 0)
        bad = int(window.get("bad_samples") or 0)
        current = summary.get("current_sample") if isinstance(summary.get("current_sample"), dict) else {}
        current_ok = bool(current.get("ok"))
        lines.append(
            f"- {window_name}: availability={availability if availability is not None else 'n/a'}% "
            f"samples={samples} bad={bad} budget_remaining_seconds={remaining}"
        )
        lines.append(f"- current_sample: {'OK' if current_ok else 'FAIL'} age={age}s")

        if samples < self.aw_rus_slo_min_samples:
            lines.append(f"- slo-alert: WARMUP samples={samples}/{self.aw_rus_slo_min_samples}")
            return lines, []
        if remaining < 0 and not current_ok:
            lines.append(f"- slo-alert: FAIL error budget exhausted ({remaining}s)")
            failures.append("slo")
        elif remaining < 0:
            lines.append(f"- slo-alert: RECOVERED error budget exhausted ({remaining}s), current sample OK")
        return lines, failures

    def _background_aw_rus_failures(self, failures: List[str]) -> Tuple[List[str], List[str]]:
        if not self._aw_rus_probe_should_run(failures):
            return [], []
        slo_lines, slo_failures = self._aw_rus_slo_lines_and_failures()
        if slo_lines:
            return self._aw_rus_failure_log_lines(slo_lines, slo_failures), slo_failures
        lines, aw_failures = self._aw_rus_dlp_probe()
        return self._aw_rus_failure_log_lines(lines, aw_failures), aw_failures

    def _aw_rus_slo_status_line(self) -> str:
        if getattr(self, "tsj_guardian_status_bin", ""):
            status_bin = Path(self.tsj_guardian_status_bin)
            if status_bin.exists():
                try:
                    proc = self._run_argv(
                        [
                            str(status_bin),
                            "--aw-slo-status-line",
                            "--aw-slo-summary-command",
                            self.aw_rus_slo_summary_cmd,
                            "--aw-slo-alert-window",
                            self.aw_rus_slo_alert_window,
                        ],
                        timeout_sec=35,
                    )
                    line = (proc.stdout or "").strip().splitlines()
                    if proc.returncode == 0 and line:
                        return line[-1]
                    if proc.returncode != 0:
                        self._log(
                            "WARN",
                            f"tsj-guardian-status aw-slo failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}",
                        )
                except Exception as exc:
                    self._log("WARN", f"tsj-guardian-status aw-slo unavailable: {exc}")

        summary = self._load_aw_rus_slo_summary()
        if not summary:
            return "- aw_rus_slo: unavailable"
        windows = summary.get("windows") if isinstance(summary.get("windows"), dict) else {}
        window_name = self.aw_rus_slo_alert_window if self.aw_rus_slo_alert_window in windows else "24h"
        window = windows.get(window_name) if isinstance(windows.get(window_name), dict) else {}
        availability = window.get("availability_percent")
        remaining = window.get("budget_remaining_seconds")
        samples = window.get("samples")
        status = window.get("status", "unknown")
        current = summary.get("current_sample") if isinstance(summary.get("current_sample"), dict) else {}
        current_ok = bool(current.get("ok"))
        display_status = status
        try:
            remaining_value = int(remaining)
        except (TypeError, ValueError):
            remaining_value = 0
        if remaining_value < 0 and current_ok:
            display_status = "recovered"
        elif remaining_value < 0 and not current_ok:
            display_status = "fail"
        availability_text = "n/a" if availability is None else f"{float(availability):.5f}%"
        return (
            f"- aw_rus_slo: {display_status} {window_name} current_sample={'OK' if current_ok else 'FAIL'} "
            f"availability={availability_text} samples={samples} budget_remaining_seconds={remaining}"
        )

    def _suggestions_from_failures(self, failures: List[str]) -> List[str]:
        decision = self._run_status_helper_json(
            ["--incident-suggestions"],
            {"failures": failures},
        )
        if isinstance(decision, dict) and isinstance(decision.get("suggestions"), list):
            suggestions = [str(item) for item in decision["suggestions"] if str(item)]
            if suggestions:
                return suggestions

        suggestions = []
        text = "\n".join(failures).lower()
        if "proxmox_api" in text:
            suggestions.append("Перезапустить pveproxy/pvedaemon/pve-cluster и проверить порт 8006.")
        if "pfsense_web" in text:
            suggestions.append("Проверить доступность pfSense 192.0.2.1:8443, перезапустить WebGUI/nginx.")
        if "pfsense_mcp" in text:
            suggestions.append("Проверить локальный pfsense-mcp-server.service, bearer token и endpoint 127.0.0.1:3010/mcp.")
        if "influxdb" in text:
            suggestions.append("Проверить контейнер InfluxDB и restart сервиса influxdb.")
        if "grafana" in text:
            suggestions.append("Проверить grafana-server и NO_PROXY для 192.0.2.0/24.")
        if "loki" in text or "alloy" in text:
            suggestions.append("Проверить LXC логов и restart сервисов loki/alloy.")
        if "aw-rus:watcher-" in text or "aw-rus:worktime:" in text:
            suggestions.append("Проверить Windows collector recovery: worktime-session-collector, ActivityWatch Recovery и Launch tasks на 198.51.100.18.")
            suggestions.append("После Windows recovery проверить server-side aw-worktime-autoheal/ui-bridge для пересборки afk/window bucket'ов.")
        if "aw-rus:dlp-" in text:
            suggestions.append("Проверить DLP endpoint/fileops collectors и server-side DLP transport на 192.0.2.13.")
        if "filesystem_usage" in text:
            suggestions.append("Проверить самые большие каталоги: du -x /var /srv /home, журналы в /var/log и apt cache.")
            suggestions.append("Проверить давление по снапшотам/хранилищу Proxmox и решить: очистка, ротация или расширение диска.")
        if not suggestions:
            suggestions.append("Запустить расширенную диагностику: /run check")
        return suggestions

    def _warning_text(self, warnings: List[str]) -> str:
        lines = [
            "Предупреждение мониторинга.",
            "",
            "Найдены filesystem warning-события:",
            *[f"- {line}" for line in warnings],
            "",
            "Критический инцидент будет создан только при достижении critical-порога.",
        ]
        return "\n".join(lines)

    def _incident_text(self, failures: List[str], suggestions: List[str]) -> str:
        lines = [
            "Обнаружен инцидент в ТСЖ системе.",
            "",
            "Проблемы:",
            *[f"- {f}" for f in failures],
            "",
            "Предложенные варианты лечения:",
            *[f"- {s}" for s in suggestions],
            "",
            f"Если оператор не ответит в течение {self.operator_timeout // 60} минут, запущу эскалацию в тех.поддержку и автономный fallback сервера.",
            "Команды: /ack, /heal, /run check, /run support, /run fallback, /status",
        ]
        return "\n".join(lines)

    def _sync_warning_state(self, warnings: List[str]) -> None:
        signature = self._warning_signature(warnings) if warnings else ""
        if not warnings:
            if self.state.last_warning_signature:
                self.state.last_warning_signature = ""
                self.state.save()
            return

        if signature == self.state.last_warning_signature:
            return

        self.state.last_warning_signature = signature
        self.state.save()
        self._notify(self._warning_text(warnings))

    def _build_ai_prompt(self, pi: PendingIncident) -> str:
        prompt = (
            "Критичный инцидент ТСЖ системы. "
            f"Incident ID: {pi.incident_id}. "
            f"Failures: {' | '.join(pi.failures)}. "
            f"Suggestions: {' | '.join(pi.suggestions)}. "
        )
        if self._has_filesystem_critical(pi.failures):
            prompt += (
                "Особое внимание: критическое заполнение файловой системы. "
                "Нужно проверить крупнейшие каталоги, логи, apt cache, снапшоты и варианты освобождения места. "
            )
        prompt += (
            "pfSense write-операции в этом пути не разрешены: firewall rules, aliases, NAT, interfaces, routes, VPN и apply/reload config "
            "можно только диагностировать, но не изменять. "
            "Проведи диагностику и лечение самостоятельно в пределах безопасных локальных действий, дай краткий отчёт оператору. "
            f"{self.HUMAN_OPERATOR_STYLE}"
        )
        return prompt

    def _handle_check_cycle(self) -> None:
        rc, out, started = self._run_check_script_once(timeout_sec=240)
        if not started:
            self._log("WARN", "Skipping check cycle because previous check is still running")
            return
        main_failures = self._parse_failures(out)
        warnings = self._parse_warnings(out)
        aw_failure_lines, _ = self._background_aw_rus_failures(main_failures)
        failures = main_failures + aw_failure_lines
        if rc == 0 and not failures:
            if self.state.pending_incident:
                self._notify("Инцидент закрыт: система снова в норме.")
                self.state.pending_incident = None
            self._reset_failure_streak()
            self._sync_warning_state(warnings)
            self.state.save()
            self._log("INFO", "Check OK")
            return

        if rc != 0 and not main_failures and not aw_failure_lines:
            self._log(
                "WARN",
                "Check command returned non-zero without explicit [FAIL] markers; suppressing empty incident",
            )
            self._notify(
                "Проверка мониторинга завершилась ошибкой запуска или парсинга без явных отказов сервисов. "
                "Критический инцидент не создаю; повторите /run check."
            )
            return

        self._sync_warning_state([])
        now = int(time.time())
        new_incident_created = False
        if not self.state.pending_incident:
            if self._defer_transient_new_incident(failures):
                return
            incident_id = time.strftime("%Y%m%d-%H%M%S")
            suggestions = self._suggestions_from_failures(failures)
            self.state.pending_incident = PendingIncident(
                incident_id=incident_id,
                created_ts=now,
                failures=failures,
                suggestions=suggestions,
                last_autoheal_ts=0,
                autoheal_attempts=0,
                operator_acked=False,
                escalated_to_ai=False,
                fallback_executed=False,
            )
            new_incident_created = True
            self._reset_failure_streak()
        else:
            self.state.pending_incident.failures = failures or self.state.pending_incident.failures
            self.state.pending_incident.suggestions = self._suggestions_from_failures(
                self.state.pending_incident.failures
            )
        self.state.save()

        pi = self.state.pending_incident
        if (
            pi
            and self.fs_immediate_ai_on_critical
            and self._has_filesystem_critical(pi.failures)
            and not pi.escalated_to_ai
        ):
            ai_ok = self._escalate_to_ai()
            pi.escalated_to_ai = ai_ok
            self.state.save()
            if ai_ok:
                self._notify("Критическое заполнение ФС: выполнена немедленная эскалация.")
            else:
                self._notify("Критическое заполнение ФС: немедленная эскалация не удалась.")

        autoheal_ok = self._attempt_autoheal(
            force=False,
            notify_success=not new_incident_created,
            notify_failure=not new_incident_created,
            exit_on_success=not new_incident_created,
        )
        if autoheal_ok:
            if new_incident_created:
                self._log("INFO", "Incident auto-resolved before operator notification")
            return

        if new_incident_created and self.state.pending_incident:
            pi = self.state.pending_incident
            self._notify(self._incident_text(pi.failures, pi.suggestions))
            self._notify(
                f"Авто-лечение неуспешно (attempt={pi.autoheal_attempts}). "
                f"Ожидаю реакцию оператора до {self.operator_timeout // 60} минут."
            )

    def _attempt_autoheal(
        self,
        force: bool,
        notify_success: bool = True,
        notify_failure: bool = True,
        exit_on_success: bool = True,
    ) -> bool:
        pi = self.state.pending_incident
        if not pi:
            return True

        now = int(time.time())
        if not force and pi.last_autoheal_ts and now - pi.last_autoheal_ts < self.retry_autoheal_sec:
            return False

        pi.last_autoheal_ts = now
        pi.autoheal_attempts += 1
        self.state.save()

        main_failures = [line for line in pi.failures if not self._is_aw_rus_failure_line(line)]
        aw_failure_keys = self._extract_aw_rus_failure_keys(pi.failures)

        generic_ok = True
        if main_failures:
            rc, out = self._run_shell(self.heal_script, timeout_sec=420)
            generic_ok = rc == 0 and not self._parse_failures(out)

        aw_ok = True
        if aw_failure_keys:
            aw_ok, aw_report, _, _ = self._perform_aw_rus_autoheal(aw_failure_keys)
            for line in aw_report:
                self._log("INFO" if "FAIL" not in line else "WARN", line)

        recheck_rc, recheck_out = self._run_shell(self.check_script, timeout_sec=240)
        recheck_main_failures = self._parse_failures(recheck_out)
        recheck_aw_lines, _ = self._background_aw_rus_failures(recheck_main_failures)
        failures = recheck_main_failures + recheck_aw_lines

        if recheck_rc == 0 and not failures and generic_ok and aw_ok:
            msg = (
                f"Авто-лечение успешно (attempt={pi.autoheal_attempts}). "
                "Инцидент закрыт."
            )
            if notify_success:
                self._notify(msg)
            self.state.pending_incident = None
            self.state.save()
            self._log("INFO", msg)
            if self.exit_on_autoheal_success and exit_on_success:
                if notify_success:
                    self._notify("По сценарию: успешное авто-лечение, процесс завершается.")
                raise SystemExit(0)
            return True

        # Keep incident active and refresh details.
        pi.failures = failures or pi.failures
        pi.suggestions = self._suggestions_from_failures(pi.failures)
        self.state.save()
        if notify_failure:
            self._notify(
                f"Авто-лечение неуспешно (attempt={pi.autoheal_attempts}). "
                f"Ожидаю реакцию оператора до {self.operator_timeout // 60} минут."
            )
        return False

    def _tmux_session_exists(self) -> bool:
        rc, _ = self._run_ai_user_shell(f"tmux has-session -t {shlex.quote(self.tmux_session)}", timeout_sec=15)
        return rc == 0

    def _escalate_to_ai_tmux(self, pi: PendingIncident) -> bool:
        if not self._tmux_session_exists():
            if self.tmux_create_if_missing:
                rc, out = self._run_ai_user_shell(
                    f"tmux new-session -d -s {shlex.quote(self.tmux_session)} {shlex.quote(self.tmux_start_cmd)}",
                    timeout_sec=20,
                )
                if rc != 0:
                    self._log("ERROR", f"Failed to create tmux session: {out}")
                    return False
            else:
                self._log("ERROR", f"tmux session '{self.tmux_session}' not found")
                return False

        prompt = self._build_ai_prompt(pi)
        safe = prompt.replace('"', '\\"')
        send_cmd = (
            f'tmux send-keys -t {shlex.quote(self.tmux_session)} "{safe}" C-m'
        )
        rc, out = self._run_ai_user_shell(send_cmd, timeout_sec=15)
        if rc != 0:
            self._log("ERROR", f"tmux escalation failed: {out}")
            return False
        self._log("ACTION", f"Escalated to AI via tmux:{self.tmux_session}")
        return True

    def _escalate_to_ai_codex_exec(self, pi: PendingIncident) -> bool:
        prompt = self._build_ai_prompt(pi)
        self._log("ACTION", f"Escalating incident to codex exec as {self.ai_exec_user}: {pi.incident_id}")
        rc, out, reply = self._run_codex_exec_prompt(prompt, timeout_sec=self.ai_chat_timeout_sec)
        if reply:
            self._notify(f"Отчёт по эскалации:\n{self._sanitize_operator_reply(reply)}")
            if rc != 0:
                self._log("WARN", f"Incident codex exec returned rc={rc} but produced final message")
            return True
        if self._is_codex_auth_error(out):
            self._log("WARN", "Incident codex exec skipped after authentication failure; server fallback remains active")
            self._notify(self._summarize_exec_error(out, rc))
            return True
        self._log("ERROR", f"Incident codex exec failed rc={rc}: {out[-2000:]}")
        return False

    def _escalate_to_ai(self) -> bool:
        if not self.enable_ai_escalation:
            return False
        pi = self.state.pending_incident
        if not pi:
            return True
        if self.ai_escalation_mode == "tmux":
            return self._escalate_to_ai_tmux(pi)
        return self._escalate_to_ai_codex_exec(pi)

    def _run_server_fallback(self) -> bool:
        if not self.enable_server_fallback:
            return False
        ok = True
        for cmd in self.server_fallback_commands:
            rc, out = self._run_shell(cmd, timeout_sec=480)
            if rc != 0:
                ok = False
                self._log("ERROR", f"Fallback command failed: {cmd}\n{out}")
            else:
                self._log("INFO", f"Fallback command OK: {cmd}")
        return ok

    def _evaluate_timeout_escalation(self) -> None:
        pi = self.state.pending_incident
        if not pi:
            return
        now = int(time.time())
        decision = self._run_status_helper_json(
            [
                "--escalation-decision",
                "--operator-timeout-seconds",
                str(self.operator_timeout),
                "--now-epoch",
                str(now),
            ],
            {"state": self._state_decision_payload()},
        )
        if isinstance(decision, dict):
            if not decision.get("timed_out"):
                return
            should_escalate = bool(decision.get("should_escalate"))
            should_fallback = bool(decision.get("should_fallback"))
        else:
            if pi.operator_acked:
                return
            if now - pi.created_ts < self.operator_timeout:
                return
            should_escalate = not pi.escalated_to_ai
            should_fallback = not pi.fallback_executed

        if should_escalate:
            ai_ok = self._escalate_to_ai()
            pi.escalated_to_ai = ai_ok
            self.state.save()
            if ai_ok:
                self._notify("Оператор не ответил. Выполнена эскалация.")
            else:
                self._notify("Оператор не ответил. Эскалация не удалась.")

        if should_fallback:
            fallback_ok = self._run_server_fallback()
            pi.fallback_executed = True
            self.state.save()
            if fallback_ok:
                self._notify("Сервер выполнил автономный fallback-план.")
            else:
                self._notify("Автономный fallback-план сервера выполнен с ошибками, требуется оператор.")

    def _cmd_help(self) -> str:
        dlp_toggle_label = self._aw_dlp_toggle_button_text()
        return (
            "Помощь по кнопкам:\n"
            "\n"
            f"{self.BTN_STATUS}\n"
            "- Показывает текущее состояние системы и инцидентов.\n"
            "- Ничего не меняет, безопасно.\n"
            "\n"
            f"{self.BTN_CHECK}\n"
            "- Запускает проверку сервисов и заполнения файловых систем (Proxmox host, CT 200/201/202/203/205).\n"
            "- Ничего не перезапускает.\n"
            "\n"
            f"{self.BTN_AW_DLP_CHECK}\n"
            "- Проверяет AW-Rus и DLP по свежести bucket-данных и сегодняшнему worktime.\n"
            "- Формирует операторский итог OK/DEGRADED прямо в чате.\n"
            "\n"
            f"{dlp_toggle_label}\n"
            "- Одной кнопкой переключает endpoint/email DLP между monitor и enforce.\n"
            "- Меняет активную policy на сервере, синхронизирует `dlp-policy.json` на Windows и перезапускает DLP collectors.\n"
            "- Web-правила остаются наблюдающими: этот toggle не превращает браузерный DLP в настоящий web-block.\n"
            "\n"
            f"{self.BTN_AW_DFIR}\n"
            "- Показывает, как запустить ограниченный forensic-разбор журналов Windows через Hayabusa.\n"
            "- Рабочий формат: `/aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]`.\n"
            "- В кейс пишется только metadata/linkage, без raw Sigma output.\n"
            "\n"
            f"{self.BTN_HEAL}\n"
            "- Пробует автоматическое лечение проблем (рестарт нужных сервисов).\n"
            "- Для критичного заполнения ФС авто-очистка не выполняется, нужен разбор причины.\n"
            "- Используйте, если диагностика показала сбой.\n"
            "\n"
            f"{self.BTN_ACK}\n"
            "- Подтверждает, что оператор взял инцидент в работу.\n"
            "- После этого автоматическая эскалация по таймауту приостанавливается.\n"
            "\n"
            f"{self.BTN_RESOLVE}\n"
            "- Закрывает текущий инцидент вручную.\n"
            "- Используйте только если уверены, что проблема решена.\n"
            "\n"
            f"{self.BTN_AI}\n"
            "- Передаёт инцидент напрямую в тех.поддержку через `codex exec` для расширенной диагностики.\n"
            "- Legacy-режим через tmux включается только служебной переменной окружения.\n"
            "- Для критического переполнения ФС это выполняется автоматически сразу.\n"
            "- Полезно, если авто-лечение не помогло.\n"
            "\n"
            f"{self.BTN_AI_CHAT}\n"
            f"- Любое свободное текстовое сообщение отправляется в тех.поддержку через `codex exec` от пользователя `{self.ai_exec_user}`.\n"
            "- Этот путь используется для нормального диалога с тех.поддержкой и не зависит от tmux.\n"
            "- Кнопка эскалации использует тот же прямой запуск `codex exec`.\n"
            "\n"
            f"{self.BTN_OVPN_CERTS}\n"
            "- Проверяет сертификаты пользователей OpenVPN в режиме чтения.\n"
            "- Можно использовать кнопку или `/openvpn_certs [filter]`.\n"
            "\n"
            f"{self.BTN_OVPN_EXPIRING}\n"
            f"- Показывает просроченные и истекающие в ближайшие {self.openvpn_expiry_warn_days} дней OpenVPN сертификаты.\n"
            "- Можно использовать кнопку или `/openvpn_expiring`.\n"
            "\n"
            f"{self.BTN_OVPN_CONFIG}\n"
            "- Запускает flow на выпуск нового OpenVPN client config для пользователя.\n"
            "- Стартуйте командой `/openvpn_config USERNAME_OR_CN`.\n"
            "- После этого нужны два отдельных подтверждения.\n"
            "\n"
            f"{self.BTN_OVPN_CONFIG_CONFIRM}\n"
            "- Первый шаг подтверждения выпуска нового OpenVPN конфига.\n"
            "\n"
            f"{self.BTN_OVPN_CONFIG_CANCEL}\n"
            "- Отменяет ожидающий выпуск OpenVPN конфига.\n"
            "\n"
            f"{self.BTN_PFSENSE_CONFIRM}\n"
            "- Первый шаг подтверждения для опасного изменения pfSense.\n"
            "- После этого бот выдаёт одноразовый код для второго подтверждения.\n"
            "\n"
            f"{self.BTN_PFSENSE_CANCEL}\n"
            "- Отменяет ожидающее изменение pfSense.\n"
            "- Используйте, если передумали или запрос нужно сформулировать заново.\n"
            "\n"
            f"{self.BTN_FALLBACK}\n"
            "- Запускает аварийный серверный план восстановления.\n"
            "- Это ручной форсированный режим, применяйте осознанно.\n"
            "\n"
            f"{self.BTN_UPD_CHECK}\n"
            "- Проверяет наличие критичных и важных обновлений на виртуальных узлах Proxmox.\n"
            "- Автоматическая установка поддерживается для LXC; неподдерживаемые VM помечаются отдельно.\n"
            "- Ежедневная авто-проверка также выполняется в 03:00 МСК.\n"
            "\n"
            f"{self.BTN_UPD_INSTALL}\n"
            "- Создаёт запрос на ручную установку критичных и важных обновлений.\n"
            "- Само обновление не запускается без подтверждения.\n"
            "\n"
            f"{self.BTN_UPD_INSTALL_CONFIRM}\n"
            "- Подтверждает и запускает обновление вручную.\n"
            "- Перед обновлением создаётся снапшот узла.\n"
            "- После обновления выполняется проверка восстановления.\n"
            "\n"
            f"{self.BTN_UPD_ROLLBACK_CONFIRM}\n"
            "- Подтверждает откат к созданному снапшоту.\n"
            "- Доступно только после неуспешного ручного обновления.\n"
            "\n"
            f"{self.BTN_PM_SNAPSHOT}\n"
            "- Запускает выбор Proxmox VM/LXC для ручного snapshot.\n"
            f"- Используется snapshot `{self.proxmox_manual_snapshot_name}`.\n"
            "- Предыдущий одноимённый snapshot на выбранном узле удаляется и заменяется новым.\n"
            "\n"
            f"{self.BTN_PM_RESTORE}\n"
            "- Запускает выбор Proxmox VM/LXC для восстановления из ручного snapshot.\n"
            f"- Восстановление выполняется из `{self.proxmox_manual_snapshot_name}`.\n"
            "- После выбора узла требуется отдельное подтверждение одноразовым кодом.\n"
            "\n"
            "Рекомендация по порядку действий:\n"
            "1) Статус -> 2) Диагностика -> 3) Лечение -> 4) Тех.поддержка (если нужно).\n"
            "\n"
            "pfSense write-flow:\n"
            "1) Отправьте текст запроса на изменение.\n"
            "2) Подтвердите первым шагом.\n"
            "3) Отправьте `/pfsense_apply CODE` для второго подтверждения.\n"
            "\n"
            "OpenVPN config flow:\n"
            "1) Отправьте `/openvpn_config USERNAME_OR_CN`.\n"
            "2) Подтвердите первым шагом.\n"
            "3) Отправьте `/openvpn_config_apply CODE`.\n"
            "\n"
            "Proxmox snapshot flow:\n"
            "1) Нажмите кнопку создания/восстановления или используйте `/proxmox_snapshot TARGET`.\n"
            "2) Для восстановления после выбора узла отправьте `/proxmox_restore_apply CODE`.\n"
            "\n"
            "Резервные slash-команды: /status /check /aw_dlp_check /dlp_mode /dlp_mode_toggle /aw_dfir PACKAGE HOST [CASE_ID] [MODE] /heal /ack /resolve /run ... /openvpn_certs [filter] /openvpn_expiring /openvpn_config USER /openvpn_config_confirm /openvpn_config_cancel /openvpn_config_apply CODE /pfsense_confirm /pfsense_cancel /pfsense_apply CODE /proxmox_snapshot TARGET /proxmox_restore TARGET /proxmox_restore_apply CODE /proxmox_restore_cancel /proxmox_selection_cancel"
        )

    def _detmir_auto_status_line(self) -> str:
        if self.tsj_guardian_status_bin:
            status_bin = Path(self.tsj_guardian_status_bin)
            if status_bin.exists():
                try:
                    proc = self._run_argv(
                        [
                            str(status_bin),
                            "--state",
                            str(self.detmir_state_file),
                        ],
                        timeout_sec=5,
                    )
                    line = (proc.stdout or "").strip().splitlines()
                    if proc.returncode == 0 and line:
                        return line[-1]
                    if proc.returncode != 0:
                        self._log(
                            "WARN",
                            f"tsj-guardian-status failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}",
                        )
                except Exception as exc:
                    self._log("WARN", f"tsj-guardian-status unavailable: {exc}")

        try:
            state = json.loads(self.detmir_state_file.read_text(encoding="utf-8"))
        except FileNotFoundError:
            return f"- detmir_auto: unavailable state_file={self.detmir_state_file}"
        except Exception as exc:
            return f"- detmir_auto: unavailable ({exc})"

        summary = state.get("detmir_summary")
        if not isinstance(summary, dict):
            check = state.get("check") if isinstance(state.get("check"), dict) else {}
            summary = check.get("summary") if isinstance(check.get("summary"), dict) else {}
        dlp_counts = state.get("dlp_counts") if isinstance(state.get("dlp_counts"), dict) else {}
        severity = str(state.get("severity", "UNKNOWN"))
        check_ok = state.get("check_ok")
        if check_ok is None and isinstance(state.get("check"), dict):
            check_ok = state["check"].get("ok")
        dlp_ok = state.get("dlp_ok")

        return (
            f"- detmir_auto: {severity} check_ok={check_ok} dlp_ok={dlp_ok} "
            f"bucket_stale={summary.get('bucket_stale', 'n/a')} "
            f"bucket_dead={summary.get('bucket_dead', 'n/a')} "
            f"service_fail={summary.get('service_failures', 'n/a')} "
            f"service_warn={summary.get('service_warnings', 'n/a')} "
            f"dlp_warn={dlp_counts.get('warn', 'n/a')} "
            f"dlp_fail={dlp_counts.get('fail', 'n/a')}"
        )

    def _cmd_status_from_rust(self) -> Optional[str]:
        if not getattr(self, "tsj_guardian_status_bin", ""):
            return None
        status_bin = Path(self.tsj_guardian_status_bin)
        if not status_bin.exists():
            return None
        pfsense_cmd = f"/usr/bin/python3 {shlex.quote(self.infra_admin_root)}/scripts/pfsense_security_status.py"
        try:
            proc = self._run_argv(
                [
                    str(status_bin),
                    "--status-text",
                    "--state",
                    str(self.detmir_state_file),
                    "--bot-state",
                    str(self.state_file),
                    "--rollback-file",
                    str(self.updates_rollback_file),
                    "--pfsense-status-command",
                    pfsense_cmd,
                    "--aw-slo-summary-command",
                    self.aw_rus_slo_summary_cmd,
                    "--aw-slo-alert-window",
                    self.aw_rus_slo_alert_window,
                ],
                timeout_sec=90,
            )
            text = (proc.stdout or "").strip()
            if proc.returncode == 0 and text:
                return text
            if proc.returncode != 0:
                self._log(
                    "WARN",
                    f"tsj-guardian-status status-text failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}",
                )
        except Exception as exc:
            self._log("WARN", f"tsj-guardian-status status-text unavailable: {exc}")
        return None

    def _cmd_status(self) -> str:
        rust_status = self._cmd_status_from_rust()
        if rust_status:
            return rust_status

        pfsense_status = self._pfsense_security_status_lines()
        aw_slo_status = self._aw_rus_slo_status_line()
        detmir_status = self._detmir_auto_status_line()
        pi = self.state.pending_incident
        if not pi:
            ppc = self.state.pending_pfsense_change
            ppc_line = (
                f"- pending_pfsense_change: {ppc.request_id} stage={ppc.stage}"
                if ppc else
                "- pending_pfsense_change: none"
            )
            ovpn_warn_line = (
                f"- openvpn_expiry_warning_signature: set"
                if self.state.last_openvpn_expiry_signature else
                "- openvpn_expiry_warning_signature: none"
            )
            pps = self.state.pending_proxmox_selection
            pps_line = (
                f"- pending_proxmox_selection: mode={pps.mode}"
                if pps else
                "- pending_proxmox_selection: none"
            )
            ppr = self.state.pending_proxmox_restore
            ppr_line = (
                f"- pending_proxmox_restore: {ppr.kind}:{ppr.guest_id} snapshot={ppr.snapshot}"
                if ppr else
                "- pending_proxmox_restore: none"
            )
            povpn = self.state.pending_openvpn_config
            povpn_line = (
                f"- pending_openvpn_config: {povpn.request_id} cn={povpn.common_name} stage={povpn.stage}"
                if povpn else
                "- pending_openvpn_config: none"
            )
            return (
                "Статус: инцидентов нет.\n"
                f"{pfsense_status}\n"
                f"{aw_slo_status}\n"
                f"{detmir_status}\n"
                f"{ppc_line}\n"
                f"{ovpn_warn_line}\n"
                f"{pps_line}\n"
                f"{ppr_line}\n"
                f"{povpn_line}\n"
                f"- pending_update_install_confirm: {self.state.pending_update_install_confirm}\n"
                f"- pending_rollback_confirm: {self.state.pending_rollback_confirm}\n"
                f"- rollback_pending_items: {self._rollback_pending_count()}"
            )
        age = int(time.time()) - pi.created_ts
        ppc = self.state.pending_pfsense_change
        ppc_line = (
            f"- pending_pfsense_change: {ppc.request_id} stage={ppc.stage}"
            if ppc else
            "- pending_pfsense_change: none"
        )
        ovpn_warn_line = (
            f"- openvpn_expiry_warning_signature: set"
            if self.state.last_openvpn_expiry_signature else
            "- openvpn_expiry_warning_signature: none"
        )
        pps = self.state.pending_proxmox_selection
        pps_line = (
            f"- pending_proxmox_selection: mode={pps.mode}"
            if pps else
            "- pending_proxmox_selection: none"
        )
        ppr = self.state.pending_proxmox_restore
        ppr_line = (
            f"- pending_proxmox_restore: {ppr.kind}:{ppr.guest_id} snapshot={ppr.snapshot}"
            if ppr else
            "- pending_proxmox_restore: none"
        )
        povpn = self.state.pending_openvpn_config
        povpn_line = (
            f"- pending_openvpn_config: {povpn.request_id} cn={povpn.common_name} stage={povpn.stage}"
            if povpn else
            "- pending_openvpn_config: none"
        )
        return (
            f"Статус: активный инцидент {pi.incident_id}\n"
            f"{pfsense_status}\n"
            f"{aw_slo_status}\n"
            f"{detmir_status}\n"
            f"- возраст: {age}s\n"
            f"- autoheal attempts: {pi.autoheal_attempts}\n"
            f"- operator_acked: {pi.operator_acked}\n"
            f"- escalated_to_ai: {pi.escalated_to_ai}\n"
            f"- fallback_executed: {pi.fallback_executed}\n"
            f"- failures: {' | '.join(pi.failures)}\n"
            f"{ppc_line}\n"
            f"{ovpn_warn_line}\n"
            f"{pps_line}\n"
            f"{ppr_line}\n"
            f"{povpn_line}\n"
            f"- pending_update_install_confirm: {self.state.pending_update_install_confirm}\n"
            f"- pending_rollback_confirm: {self.state.pending_rollback_confirm}\n"
            f"- rollback_pending_items: {self._rollback_pending_count()}"
        )

    def _aw_rus_dlp_probe(self) -> Tuple[List[str], List[str]]:
        base = self.aw_rus_api_base.rstrip("/")
        worktime_base = self.aw_rus_worktime_base.rstrip("/")
        host = self.aw_rus_host
        now = datetime.now(timezone.utc)

        def latest_bucket_event(bucket_id: str) -> Tuple[Optional[Dict], str]:
            try:
                r = requests.get(f"{base}/buckets/{bucket_id}/events?limit=20", timeout=20)
                r.raise_for_status()
                events = r.json()
                if not isinstance(events, list) or not events:
                    return None, "no-events"

                latest_event = None
                latest_ts = None
                for event in events:
                    raw_ts = event.get("timestamp") if isinstance(event, dict) else None
                    if not raw_ts:
                        continue
                    try:
                        event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")).astimezone(timezone.utc)
                    except Exception:
                        continue
                    if latest_ts is None or event_ts > latest_ts:
                        latest_ts = event_ts
                        latest_event = event
                if latest_event is None:
                    return None, "no-valid-events"
                return latest_event, str(latest_event.get("timestamp") or "unknown")
            except Exception as exc:
                return None, f"events-error:{exc}"

        def bucket_age(bucket_id: str) -> Tuple[Optional[int], str]:
            event, event_tail = latest_bucket_event(bucket_id)
            if event:
                end = str(event.get("timestamp") or "").strip()
                try:
                    end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")).astimezone(timezone.utc)
                    age = int((now - end_dt).total_seconds())
                    return age, end
                except Exception as exc:
                    return None, f"event-ts-error:{exc}"

            try:
                r = requests.get(f"{base}/buckets/{bucket_id}", timeout=20)
                r.raise_for_status()
                data = r.json()
                end = ((data.get("metadata") or {}).get("end") or "").strip()
                if not end:
                    return None, "no-end"
                end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")).astimezone(timezone.utc)
                age = int((now - end_dt).total_seconds())
                return age, end
            except Exception as exc:
                return None, f"{event_tail}; metadata-error:{exc}"

        def load_worktime_activity() -> Tuple[Optional[Dict[str, Dict[str, Optional[int]]]], Optional[str]]:
            try:
                r = requests.get(f"{base}/buckets", timeout=20)
                r.raise_for_status()
                buckets = r.json()
            except Exception as exc:
                return None, f"error:{exc}"

            prefix = "aw-worktime-sessions_"
            activity: Dict[str, Dict[str, Optional[int]]] = {}
            for bucket_id in sorted(key for key in buckets if key.startswith(prefix)):
                bucket_host = bucket_id[len(prefix):]
                latest_ts = None
                latest_active = False
                try:
                    r = requests.get(f"{base}/buckets/{bucket_id}/events?limit=20", timeout=20)
                    r.raise_for_status()
                    events = r.json()
                except Exception as exc:
                    activity[bucket_host] = {
                        "active": False,
                        "age_seconds": None,
                        "error": str(exc),
                    }
                    continue

                if isinstance(events, list):
                    for event in events:
                        raw_ts = event.get("timestamp")
                        if not raw_ts:
                            continue
                        try:
                            event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")).astimezone(timezone.utc)
                        except Exception:
                            continue
                        if latest_ts is None or event_ts > latest_ts:
                            latest_ts = event_ts
                            latest_active = bool((event.get("data") or {}).get("active"))

                age_seconds = None
                if latest_ts is not None:
                    age_seconds = max(0, int((now - latest_ts).total_seconds()))
                activity[bucket_host] = {
                    "active": bool(latest_ts and latest_active and (age_seconds or 0) <= self.aw_rus_stale_sec),
                    "age_seconds": age_seconds,
                    "fresh": bool(latest_ts and age_seconds is not None and age_seconds <= self.aw_rus_stale_sec),
                }

            return activity, None

        lines = ["Проверка AW-Rus + DLP:"]
        failures: List[str] = []
        worktime_activity, worktime_error = load_worktime_activity()
        current_host_activity = worktime_activity.get(host) if worktime_activity else None
        interactive_required = bool(current_host_activity and current_host_activity.get("active"))

        guard_event, guard_tail = latest_bucket_event(f"aw-rus-collector-guard_{host}")
        guard_data = (guard_event or {}).get("data") if guard_event else {}
        guard_problems = guard_data.get("problems") if isinstance(guard_data, dict) else []
        guard_actions = guard_data.get("actions") if isinstance(guard_data, dict) else []
        guard_age = None
        if guard_event:
            try:
                guard_ts = datetime.fromisoformat(str(guard_event["timestamp"]).replace("Z", "+00:00")).astimezone(timezone.utc)
                guard_age = int((now - guard_ts).total_seconds())
            except Exception:
                guard_age = None
        guard_healthy = bool(
            guard_age is not None
            and guard_age <= max(300, self.aw_rus_stale_sec)
            and isinstance(guard_data, dict)
            and guard_data.get("status") == "ok"
            and not guard_problems
        )
        if guard_event:
            mode = guard_data.get("mode", "unknown") if isinstance(guard_data, dict) else "unknown"
            if guard_healthy:
                lines.append(f"- collector-guard: OK age={guard_age}s mode={mode} actions={len(guard_actions or [])}")
            else:
                lines.append(f"- collector-guard: WARN age={guard_age}s mode={mode} tail={guard_tail}")
        else:
            lines.append(f"- collector-guard: WARN ({guard_tail})")

        checks = [
            (f"aw-watcher-window_{host}", "watcher-window"),
            (f"aw-watcher-afk_{host}", "watcher-afk"),
            (f"aw-dlp-endpoint-signals_{host}", "dlp-endpoint"),
        ]
        bucket_status: Dict[str, Dict[str, Optional[int] | str | bool]] = {}
        endpoint_line_index: Optional[int] = None
        for bucket_id, label in checks:
            age, tail = bucket_age(bucket_id)
            bucket_status[label] = {"age": age, "tail": tail, "fresh": bool(age is not None and age <= self.aw_rus_stale_sec)}
            if label == "dlp-endpoint":
                endpoint_line_index = len(lines)
            if age is None:
                if label.startswith("watcher-") and not interactive_required:
                    lines.append(f"- {label}: WARN ({tail}; host inactive)")
                elif label == "dlp-endpoint" and not interactive_required and guard_healthy:
                    lines.append(f"- {label}: WARN ({tail}; host inactive; guard healthy)")
                else:
                    lines.append(f"- {label}: FAIL ({tail})")
                    failures.append(label)
                continue
            if age > self.aw_rus_stale_sec:
                if label.startswith("watcher-") and not interactive_required:
                    lines.append(f"- {label}: WARN age={age}s end={tail} (host inactive)")
                elif label == "dlp-endpoint" and not interactive_required and guard_healthy:
                    lines.append(f"- {label}: WARN age={age}s end={tail} (host inactive; guard healthy)")
                else:
                    lines.append(f"- {label}: STALE age={age}s end={tail}")
                    failures.append(label)
            else:
                lines.append(f"- {label}: OK age={age}s end={tail}")

        fileops_checks = [
            (f"aw-file-operations_{host}", "dlp-fileops-host", host),
            ("aw-file-operations_192.0.2.13", "dlp-fileops-server", "192.0.2.13"),
        ]
        for bucket_id, label, bucket_host in fileops_checks:
            if worktime_activity is None:
                age, tail = bucket_age(bucket_id)
                if age is None:
                    lines.append(f"- {label}: FAIL (worktime-map {worktime_error}; bucket {tail})")
                    failures.append(label)
                    continue
                if age > self.aw_rus_stale_sec:
                    lines.append(f"- {label}: STALE age={age}s end={tail} (worktime-map unavailable)")
                    failures.append(label)
                else:
                    lines.append(f"- {label}: OK age={age}s end={tail} (worktime-map unavailable)")
                continue

            host_meta = worktime_activity.get(bucket_host)
            if host_meta is None:
                lines.append(f"- {label}: OK unmanaged host={bucket_host}")
                continue
            if not host_meta.get("active"):
                age_seconds = host_meta.get("age_seconds")
                age_tail = f" age={age_seconds}s" if age_seconds is not None else ""
                lines.append(f"- {label}: OK inactive host={bucket_host}{age_tail}")
                continue

            age, tail = bucket_age(bucket_id)
            bucket_status[label] = {"age": age, "tail": tail, "fresh": bool(age is not None and age <= self.aw_rus_stale_sec)}
            if age is None:
                lines.append(f"- {label}: FAIL (active host bucket missing or unreadable: {tail})")
                failures.append(label)
                continue
            if age > self.aw_rus_stale_sec:
                if label == "dlp-fileops-host" and bool((bucket_status.get("dlp-endpoint") or {}).get("fresh")):
                    lines.append(f"- {label}: OK event-driven age={age}s end={tail} (endpoint fresh)")
                    continue
                lines.append(f"- {label}: STALE age={age}s end={tail}")
                failures.append(label)
            else:
                lines.append(f"- {label}: OK age={age}s end={tail}")

        endpoint_status = bucket_status.get("dlp-endpoint") or {}
        if (
            endpoint_line_index is not None
            and "dlp-endpoint" in failures
            and bool((bucket_status.get("dlp-fileops-host") or {}).get("fresh"))
            and endpoint_status.get("age") is not None
        ):
            endpoint_age = int(endpoint_status["age"])
            endpoint_tail = str(endpoint_status.get("tail") or "unknown")
            failures = [item for item in failures if item != "dlp-endpoint"]
            lines[endpoint_line_index] = (
                f"- dlp-endpoint: WARN age={endpoint_age}s end={endpoint_tail} "
                "(file-operations fresh; endpoint collector degraded)"
            )

        try:
            csv_text = self._fetch_worktime_today_csv(timeout_sec=20, attempts=2)
            target = self.aw_rus_primary_user.upper()
            active_sec = None
            session_rows = self._parse_worktime_today_csv(csv_text)
            for row in session_rows:
                user = (row.get("user") or "").upper()
                if user == target:
                    active_sec = row.get("active_seconds")

            non_machine_rows = [
                row for row in session_rows
                if row.get("user") and not row["user"].endswith("$")
            ]
            positive_rows = [
                row for row in non_machine_rows
                if (row.get("active_seconds") or 0) > 0
            ]
            if positive_rows:
                if active_sec is not None and active_sec > 0:
                    lines.append(f"- worktime({target}): OK active_seconds={active_sec}")
                elif active_sec is not None:
                    users = ", ".join(str(row["user"]) for row in positive_rows[:3])
                    lines.append(f"- worktime({target}): OK target idle, other active users={users}")
                else:
                    users = ", ".join(str(row["user"]) for row in positive_rows[:3])
                    lines.append(f"- worktime({target}): OK active users present={users}")
            elif current_host_activity and current_host_activity.get("active"):
                lines.append(f"- worktime({target}): STALE active sessions present but report has no positive rows")
                failures.append("worktime")
            else:
                lines.append(f"- worktime({target}): OK active_seconds=0 (no active sessions)")
        except Exception as exc:
            if current_host_activity and current_host_activity.get("fresh"):
                age_seconds = current_host_activity.get("age_seconds")
                age_tail = f"; session bucket age={age_seconds}s" if age_seconds is not None else ""
                lines.append(f"- worktime: WARN report unavailable ({exc}{age_tail})")
            else:
                lines.append(f"- worktime: FAIL ({exc})")
                failures.append("worktime")

        return lines, failures

    def _aw_rus_dlp_status_text(self) -> str:
        lines, failures = self._aw_rus_dlp_probe()
        verdict = "OK" if not failures else f"DEGRADED ({', '.join(failures)})"
        lines.append(f"Итог: {verdict}")
        return "\n".join(lines)

    def _aw_rus_dlp_heal(self, targets: List[str]) -> Tuple[bool, List[str]]:
        base = self.aw_rus_api_base.rstrip("/")
        host = self.aw_rus_host
        now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
        report: List[str] = []

        bucket_defs = {
            f"aw-dlp-endpoint-signals_{host}": ("aw.dlp.endpoint.signal", "aw-dlp-endpoint-signals", host),
            f"aw-file-operations_{host}": ("aw.file.operation", "aw-file-operations", host),
            "aw-file-operations_192.0.2.13": ("aw.file.operation", "aw-file-operations", "192.0.2.13"),
        }
        map_fail_to_bucket = {
            "dlp-endpoint": f"aw-dlp-endpoint-signals_{host}",
            "dlp-fileops-host": f"aw-file-operations_{host}",
            "dlp-fileops-server": "aw-file-operations_192.0.2.13",
        }

        selected = []
        for key in targets:
            bid = map_fail_to_bucket.get(key)
            if bid and bid not in selected:
                selected.append(bid)

        if not selected:
            report.append("- heal: skipped (no DLP targets)")
            return True, report

        cmd = (self.aw_rus_dlp_heal_cmd or "").strip()
        if cmd:
            try:
                rc, out = self._run_shell(cmd, timeout_sec=120)
                if rc != 0:
                    tail = (out or "").strip().splitlines()[-1:] or [f"rc={rc}"]
                    report.append(f"- dlp-heal: FAIL ({tail[0]})")
                    return False, report
                report.append("- dlp-heal: configured recovery command OK")
                return True, report
            except Exception as exc:
                report.append(f"- dlp-heal: FAIL ({exc})")
                return False, report

        report.append("- dlp-heal: no configured recovery command, using freshness reseed fallback")
        ok = True
        for bucket_id in selected:
            btype, client, hostname = bucket_defs[bucket_id]
            try:
                requests.post(
                    f"{base}/buckets/{bucket_id}",
                    json={"type": btype, "client": client, "hostname": hostname},
                    timeout=20,
                )
                event = {
                    "timestamp": now_iso,
                    "duration": 0,
                    "data": {"source": "tsj-guardian-heal", "signalType": "self_test", "hostname": hostname},
                }
                r = requests.post(f"{base}/buckets/{bucket_id}/events", json=[event], timeout=20)
                r.raise_for_status()
                report.append(f"- heal {bucket_id}: OK")
            except Exception as exc:
                ok = False
                report.append(f"- heal {bucket_id}: FAIL ({exc})")

        return ok, report

    def _aw_rus_worktime_heal(self) -> Tuple[bool, List[str]]:
        report: List[str] = []
        cmd = (self.aw_rus_worktime_heal_cmd or "").strip()
        if not cmd:
            report.append("- worktime-heal: skipped (command not configured)")
            return False, report
        try:
            rc, out = self._run_shell(cmd, timeout_sec=90)
            if rc != 0:
                tail = (out or "").strip().splitlines()[-1:] or [f"rc={rc}"]
                report.append(f"- worktime-heal: FAIL ({tail[0]})")
                return False, report
            report.append("- worktime-heal: restart command OK")
        except Exception as exc:
            report.append(f"- worktime-heal: FAIL ({exc})")
            return False, report

        time.sleep(2)
        try:
            self._fetch_worktime_today_csv(timeout_sec=8, attempts=2)
            report.append("- worktime-heal: probe OK")
            return True, report
        except Exception as exc:
            report.append(f"- worktime-heal: probe FAIL ({exc})")
            return False, report

    def _run_aw_rus_windows_powershell(self, script: str, timeout_sec: int = 180) -> Tuple[int, str]:
        encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii")
        argv = [
            self.sshpass_bin,
            "-e",
            "ssh",
            "-o",
            "PubkeyAuthentication=no",
            "-o",
            "StrictHostKeyChecking=no",
            "-o",
            "ConnectTimeout=12",
            f"{self.aw_rus_windows_ssh_user}@{self.aw_rus_windows_host}",
            f"powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand {encoded}",
        ]
        proc = self._run_argv(
            argv,
            timeout_sec=timeout_sec,
            env_extra={"SSHPASS": self.aw_rus_windows_ssh_password},
        )
        out = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
        return proc.returncode, out.strip()

    def _aw_rus_windows_collectors_heal(
        self,
        include_watchers: bool,
        include_worktime: bool,
        include_dlp: bool = False,
    ) -> Tuple[bool, List[str]]:
        report: List[str] = []
        if not (include_watchers or include_worktime or include_dlp):
            report.append("- windows-heal: skipped (no Windows collector targets)")
            return True, report
        if not self.aw_rus_windows_ssh_password:
            report.append("- windows-heal: FAIL (AW_RUS_WINDOWS_SSH_PASSWORD not configured)")
            return False, report

        task_items = ", ".join("'" + task.replace("'", "''") + "'" for task in self.aw_rus_windows_launch_tasks)
        launch_tasks_expr = f"@({task_items})" if task_items else "@()"
        script = (
            "$ErrorActionPreference = 'Stop'\n"
            "$ProgressPreference = 'SilentlyContinue'\n"
            f"$sessionCollectorScript = '{self.aw_rus_windows_session_collector_path}'\n"
            f"$launchTasks = {launch_tasks_expr}\n"
            "$stopSessionCollector = " + ("$true" if include_worktime else "$false") + "\n"
            "$runRecovery = " + ("$true" if (include_watchers or include_worktime) else "$false") + "\n"
            "if ($stopSessionCollector) {\n"
            "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n"
            "  Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine -and $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) } |\n"
            "  ForEach-Object { Stop-Process -Id ([int]$_.ProcessId) -Force -ErrorAction SilentlyContinue }\n"
            "}\n"
            "if ($runRecovery) {\n"
            f"  if (Test-Path '{self.aw_rus_windows_hardening_recovery_path}') {{ try {{ & '{self.aw_rus_windows_hardening_recovery_path}' -ConfigPath '{self.aw_rus_windows_config_path}' }} catch {{ Write-Output ('HARDENING_RECOVERY_WARN: ' + $_.Exception.Message) }} }}\n"
            "  Start-Sleep -Seconds 2\n"
            "  schtasks /Run /TN 'ActivityWatch Recovery' | Out-Null\n"
            "}\n"
            "Start-Sleep -Seconds 2\n"
            "$allTasks = Get-ScheduledTask -ErrorAction SilentlyContinue\n"
            "foreach ($taskName in $launchTasks) {\n"
            "  $task = $allTasks | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1\n"
            "  if (-not $task) { Write-Output ('MISSING ' + $taskName); continue }\n"
            "  if ($task.State -eq 'Running') { Write-Output ('SKIP running ' + $taskName); continue }\n"
            "  try { schtasks /Run /TN $taskName | Out-String | Write-Output } catch { Write-Output ('START_WARN ' + $taskName + ': ' + $_.Exception.Message) }\n"
            "}\n"
            "Start-Sleep -Seconds 20\n"
            "Write-Output 'WATCHERS'\n"
            "Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue |\n"
            "  Select-Object Name, Id, SessionId, StartTime |\n"
            "  Sort-Object SessionId, Name |\n"
            "  Format-Table -AutoSize\n"
            "Write-Output 'AW_POWERSHELL'\n"
            "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n"
            "  Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine -and $_.CommandLine -match 'AWatch-rus' } |\n"
            "  Select-Object Name, ProcessId, SessionId, CommandLine |\n"
            "  Format-List\n"
        )

        try:
            rc, out = self._run_aw_rus_windows_powershell(script, timeout_sec=180)
        except Exception as exc:
            report.append(f"- windows-heal: FAIL ({exc})")
            return False, report
        if rc != 0:
            tail = (out or "").strip().splitlines()[-8:]
            report.append(f"- windows-heal: FAIL ({'; '.join(tail) if tail else f'rc={rc}'})")
            return False, report
        report.append("- windows-heal: recovery command OK")
        return True, report

    def _perform_aw_rus_autoheal(self, failures: List[str]) -> Tuple[bool, List[str], List[str], List[str]]:
        if "slo" in failures:
            slo_lines, slo_failures = self._aw_rus_slo_lines_and_failures()
            stale = any(line.strip().startswith("- slo-summary: STALE") for line in slo_lines)
            plan = self._autoheal_plan_decision(failures, slo_stale=stale)
            if isinstance(plan, dict) and plan.get("slo_only"):
                triggers = [str(item) for item in (plan.get("report_triggers") or []) if str(item)]
                return False, triggers, slo_lines, slo_failures
            trigger = (
                "- heal trigger: SLO summary stale, check aw-slo-monitor.timer/service"
                if stale
                else "- heal trigger: SLO error budget exhausted, no direct autoheal target"
            )
            return False, [trigger], slo_lines, slo_failures

        plan = self._autoheal_plan_decision(failures)
        if isinstance(plan, dict):
            watcher_failed = bool(plan.get("include_watchers"))
            worktime_failed = bool(plan.get("include_worktime"))
            windows_dlp_failed = bool(plan.get("include_windows_dlp"))
            server_dlp_failures = [str(item) for item in (plan.get("server_dlp_failures") or []) if str(item)]
            run_windows_heal = bool(plan.get("run_windows_heal"))
            run_server_dlp_heal = bool(plan.get("run_server_dlp_heal"))
            run_worktime_heal = bool(plan.get("run_worktime_heal"))
            report_triggers = [str(item) for item in (plan.get("report_triggers") or []) if str(item)]
            sleep_after_seconds = int(plan.get("sleep_after_seconds") or 5)
        else:
            watcher_failures = [item for item in failures if item.startswith("watcher-")]
            dlp_failures = [item for item in failures if item.startswith("dlp-")]
            windows_dlp_failures = [item for item in dlp_failures if item in {"dlp-endpoint", "dlp-fileops-host"}]
            server_dlp_failures = [item for item in dlp_failures if item not in set(windows_dlp_failures)]
            worktime_failed = "worktime" in failures
            watcher_failed = bool(watcher_failures)
            windows_dlp_failed = bool(windows_dlp_failures)
            run_windows_heal = watcher_failed or worktime_failed or windows_dlp_failed
            run_server_dlp_heal = bool(server_dlp_failures)
            run_worktime_heal = watcher_failed or worktime_failed
            report_triggers = []
            if run_windows_heal:
                report_triggers.append("- heal trigger: Windows session collectors degraded, starting remediation")
            if run_server_dlp_heal:
                report_triggers.append("- heal trigger: server-side DLP degraded, starting remediation")
            if run_worktime_heal:
                report_triggers.append("- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views")
            sleep_after_seconds = 30 if (watcher_failed or windows_dlp_failed) else 5
        report: List[str] = []
        heal_ok = True

        if run_windows_heal:
            windows_ok, windows_lines = self._aw_rus_windows_collectors_heal(
                include_watchers=watcher_failed,
                include_worktime=worktime_failed,
                include_dlp=windows_dlp_failed,
            )
            heal_ok = heal_ok and windows_ok
            if report_triggers:
                report.append(report_triggers.pop(0))
            else:
                report.append("- heal trigger: Windows session collectors degraded, starting remediation")
            report.extend(windows_lines)

        if run_server_dlp_heal:
            dlp_ok, dlp_lines = self._aw_rus_dlp_heal(server_dlp_failures)
            heal_ok = heal_ok and dlp_ok
            trigger = next((item for item in report_triggers if "server-side DLP" in item), None)
            report.append(trigger or "- heal trigger: server-side DLP degraded, starting remediation")
            report.extend(dlp_lines)

        if run_worktime_heal:
            wt_ok, wt_lines = self._aw_rus_worktime_heal()
            heal_ok = heal_ok and wt_ok
            trigger = next((item for item in report_triggers if "worktime/watchers" in item), None)
            report.append(trigger or "- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views")
            report.extend(wt_lines)

        time.sleep(sleep_after_seconds)
        after_lines, after_failures = self._aw_rus_dlp_probe()
        return heal_ok and not after_failures, report, after_lines, after_failures

    def _aw_rus_dlp_check_and_heal_text(self) -> str:
        before_lines, failures = self._aw_rus_dlp_probe()
        watcher_failures = [x for x in failures if x.startswith("watcher-")]
        dlp_failures = [x for x in failures if x.startswith("dlp-")]
        worktime_failed = "worktime" in failures

        if not watcher_failures and not dlp_failures and not worktime_failed:
            verdict = "OK" if not failures else f"DEGRADED ({', '.join(failures)})"
            before_lines.append(f"Итог: {verdict}")
            return "\n".join(before_lines)

        out = []
        out.extend(before_lines)

        heal_ok, heal_lines, after_lines, after_failures = self._perform_aw_rus_autoheal(failures)
        out.extend(heal_lines)
        verdict_after = "OK" if not after_failures else f"DEGRADED ({', '.join(after_failures)})"
        after_lines.append(f"Итог: {verdict_after}")

        out.append(f"- heal status: {'OK' if heal_ok else 'FAILED'}")
        out.append("После лечения:")
        out.extend(after_lines)
        return "\n".join(out)

    def _aw_rus_hayabusa_usage_text(self) -> str:
        return (
            "Форензика Windows логов:\n"
            "- ограниченный forensic-путь для EVTX package -> Hayabusa -> case linkage.\n"
            "- рабочий запуск: /aw_dfir /path/to/package.zip HOST [CASE_ID] [MODE]\n"
            "- MODE по умолчанию: incident\n"
            "- в кейс пишутся только metadata и ссылки на артефакты, без raw Sigma output."
        )

    @staticmethod
    def _extract_marked_block(text: str, begin_marker: str, end_marker: str) -> str:
        start = text.find(begin_marker)
        end = text.find(end_marker)
        if start < 0 or end < 0 or end <= start:
            return ""
        return text[start + len(begin_marker):end].strip()

    def _aw_rus_case_link_hayabusa(self, case_id: int, payload: Dict) -> None:
        response = requests.post(
            f"{self.aw_rus_case_api_base.rstrip('/')}/api/0/dlp/cases/{int(case_id)}/forensics/hayabusa",
            json=payload,
            timeout=20,
        )
        response.raise_for_status()

    def _aw_rus_hayabusa_run(
        self,
        package_path: str,
        host: str | None = None,
        case_id: int | None = None,
        mode: str = "incident",
    ) -> str:
        if not self.aw_rus_hayabusa_enabled:
            return "Hayabusa DFIR trigger отключён."
        package_path = (package_path or "").strip()
        host = (host or "").strip() or None
        mode = (mode or "incident").strip().lower() or "incident"
        if not package_path:
            return self._aw_rus_hayabusa_usage_text()
        if mode not in {"quick", "incident", "full"}:
            return f"Неверный mode: {mode}. Допустимо: quick, incident, full."

        accept_cmd = f"sudo /usr/local/bin/aw-hayabusa accept --package {shlex.quote(package_path)}"
        if host:
            accept_cmd += f" --host {shlex.quote(host)}"
        remote_script = (
            "set -eu\n"
            f"{accept_cmd}\n"
            "process_rc=0\n"
            f"sudo /usr/local/bin/aw-hayabusa process-inbox --mode {shlex.quote(mode)} --limit 1 || process_rc=$?\n"
            "echo '__AW_HAYA_INTAKE_JSON_BEGIN__'\n"
            "sudo cat /opt/hayabusa/state/latest-intake.json\n"
            "echo '__AW_HAYA_INTAKE_JSON_END__'\n"
            "exit \"$process_rc\"\n"
        )
        cmd = f"{self.aw_rus_hayabusa_ssh_cmd} bash -lc {shlex.quote(remote_script)}"
        rc, out = self._run_shell(cmd, timeout_sec=900)
        json_block = self._extract_marked_block(out, "__AW_HAYA_INTAKE_JSON_BEGIN__", "__AW_HAYA_INTAKE_JSON_END__")
        if not json_block:
            tail = "\n".join((out or "").splitlines()[-20:])
            return f"Hayabusa DFIR: не удалось получить intake metadata.\nrc={rc}\n{tail}"
        try:
            intake = json.loads(json_block)
        except Exception as exc:
            tail = "\n".join((out or "").splitlines()[-20:])
            return f"Hayabusa DFIR: intake metadata повреждены ({exc}).\nrc={rc}\n{tail}"

        report_dir = intake.get("report_dir") or ""
        report_dir = str(report_dir)
        status = str(intake.get("status") or ("ok" if rc == 0 else f"rc-{rc}"))
        link_payload = {
            "host": str(intake.get("host") or host or self.aw_rus_host),
            "mode": mode,
            "status": status,
            "intake_id": intake.get("intake_id"),
            "package_path": intake.get("package_path"),
            "sha256": intake.get("sha256"),
            "report_dir": report_dir or None,
            "summary_html": f"{report_dir}/summary.html" if report_dir else None,
            "timeline_path": f"{report_dir}/timeline.jsonl" if report_dir else None,
            "manifest_path": f"{report_dir}/manifest.json" if report_dir else None,
            "link_source": "telegram-bot",
        }
        linked_line = "- case linkage: skipped"
        if case_id is not None:
            self._aw_rus_case_link_hayabusa(case_id, link_payload)
            linked_line = f"- case linkage: OK case_id={case_id}"

        verdict = "OK" if rc == 0 else f"DEGRADED rc={rc}"
        lines = [
            f"Hayabusa DFIR: {verdict}",
            f"- host: {link_payload['host']}",
            f"- mode: {mode}",
            f"- status: {status}",
            f"- intake_id: {intake.get('intake_id') or '-'}",
            f"- package: {intake.get('package_path') or package_path}",
            f"- report_dir: {report_dir or '-'}",
            linked_line,
        ]
        if rc != 0:
            tail = "\n".join((out or "").splitlines()[-20:])
            lines.append("- runner tail:")
            lines.append(tail)
        return "\n".join(lines)

    def _pfsense_security_status_lines(self) -> str:
        cmd = f"/usr/bin/python3 {shlex.quote(self.infra_admin_root)}/scripts/pfsense_security_status.py"
        try:
            rc, out = self._run_shell(cmd, timeout_sec=40)
        except Exception as exc:
            return f"- pfsense_security: status unavailable ({exc})"

        lines = [line.strip() for line in (out or "").splitlines() if line.strip()]
        if rc != 0 or not lines:
            err = lines[-1] if lines else f"rc={rc}"
            return f"- pfsense_security: status unavailable ({err})"
        return "\n".join(lines)

    def _aw_dlp_policy_request(
        self,
        method: str,
        path: str,
        payload: Optional[Dict] = None,
        timeout_sec: int = 20,
    ) -> Dict:
        base = self.aw_dlp_policy_api_base.rstrip("/")
        if not base:
            raise RuntimeError("AW_DLP_POLICY_API_BASE is not configured")
        url = f"{base}/{path.lstrip('/')}"
        response = requests.request(method.upper(), url, json=payload, timeout=timeout_sec)
        response.raise_for_status()
        if not (response.text or "").strip():
            return {}
        return response.json()

    @staticmethod
    def _aw_dlp_toggle_rule_groups(policy: Dict) -> List[Tuple[str, List[Dict]]]:
        endpoint = policy.get("endpoint")
        if not isinstance(endpoint, dict):
            return []
        groups: List[Tuple[str, List[Dict]]] = []
        for key in ("clipboard", "usb", "print", "email"):
            rules = endpoint.get(key)
            if isinstance(rules, list):
                groups.append((f"endpoint.{key}", rules))
        return groups

    @classmethod
    def _aw_dlp_mode_from_policy(cls, policy: Dict) -> str:
        meta = policy.get("_tsj_meta")
        if isinstance(meta, dict):
            mode = str(meta.get("dlp_mode") or "").strip().lower()
            if mode in {"monitor", "enforce"}:
                return mode

        total = 0
        blocked = 0
        for _, rules in cls._aw_dlp_toggle_rule_groups(policy):
            for rule in rules:
                if not isinstance(rule, dict) or rule.get("enabled") is False:
                    continue
                action = str(rule.get("action") or "").strip().lower()
                if not action:
                    continue
                total += 1
                if action == "block":
                    blocked += 1
        if blocked == 0:
            return "monitor"
        if blocked == total:
            return "enforce"
        return "mixed"

    @classmethod
    def _aw_dlp_policy_for_mode(cls, policy: Dict, target_mode: str) -> Tuple[Dict, int, List[str]]:
        if target_mode not in {"monitor", "enforce"}:
            raise ValueError(f"Unsupported DLP mode: {target_mode}")

        updated = json.loads(json.dumps(policy, ensure_ascii=False))
        changed = 0
        changed_rules: List[str] = []
        for group_name, rules in cls._aw_dlp_toggle_rule_groups(updated):
            for rule in rules:
                if not isinstance(rule, dict) or rule.get("enabled") is False:
                    continue
                old_action = str(rule.get("action") or "").strip().lower() or "alert"
                new_action = old_action
                if target_mode == "monitor":
                    if old_action == "block":
                        new_action = "alert"
                else:
                    if old_action in {"log", "alert"}:
                        new_action = "block"
                if new_action == old_action:
                    continue
                rule["action"] = new_action
                changed += 1
                if len(changed_rules) < 10:
                    changed_rules.append(f"{group_name}:{rule.get('id') or '?'} {old_action}->{new_action}")

        meta = updated.get("_tsj_meta")
        if not isinstance(meta, dict):
            meta = {}
        meta["dlp_mode"] = target_mode
        meta["updated_by"] = "tsj-guardian-bot"
        meta["updated_at_utc"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
        updated["_tsj_meta"] = meta
        return updated, changed, changed_rules

    def _aw_dlp_policy_decision(self, policy: Dict, target_mode: str = "") -> Optional[Dict]:
        decision = self._run_status_helper_json(
            [
                "--dlp-policy-decision",
                "--now-epoch",
                str(int(time.time())),
            ],
            {
                "policy": policy if isinstance(policy, dict) else {},
                "target_mode": target_mode,
            },
        )
        if isinstance(decision, dict):
            return decision
        return None

    def _aw_rus_windows_sync_dlp_policy(self, policy: Dict) -> Tuple[bool, List[str]]:
        report: List[str] = []
        if not self.aw_rus_windows_ssh_password:
            report.append("- windows-policy-sync: FAIL (AW_RUS_WINDOWS_SSH_PASSWORD not configured)")
            return False, report
        script = (
            "$ErrorActionPreference = 'Stop'\n"
            "$ProgressPreference = 'SilentlyContinue'\n"
            f"$policyPath = '{self.aw_rus_windows_policy_path}'\n"
            f"$policyApiBase = '{self.aw_dlp_policy_api_base.rstrip('/')}'\n"
            f"$browserCollector = '{self.aw_rus_windows_browser_collector_path}'\n"
            "$dlpEndpointCollector = 'C:\\ProgramData\\AWatch-rus\\dlp-endpoint-signals-collector.ps1'\n"
            f"$emailCollector = '{self.aw_rus_windows_email_collector_path}'\n"
            "$bundle = Invoke-RestMethod -Uri ($policyApiBase + '/dlp/policies/active') -TimeoutSec 20 -Method Get\n"
            "if (-not $bundle.policy) { throw 'Active policy payload is empty' }\n"
            "$json = $bundle.policy | ConvertTo-Json -Depth 20\n"
            "$dir = Split-Path -Path $policyPath -Parent\n"
            "if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null }\n"
            "Set-Content -LiteralPath $policyPath -Value $json -Encoding UTF8\n"
            "$collectorScripts = @($browserCollector, $emailCollector, $dlpEndpointCollector)\n"
            "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n"
            "  Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine } |\n"
            "  ForEach-Object {\n"
            "    $commandLine = [string]$_.CommandLine\n"
            "    foreach ($scriptPath in $collectorScripts) {\n"
            "      if (-not [string]::IsNullOrWhiteSpace($scriptPath) -and $commandLine -match [Regex]::Escape($scriptPath)) {\n"
            "        Stop-Process -Id ([int]$_.ProcessId) -Force -ErrorAction SilentlyContinue\n"
            "        break\n"
            "      }\n"
            "    }\n"
            "  }\n"
            f"if (Test-Path '{self.aw_rus_windows_hardening_recovery_path}') {{ try {{ & '{self.aw_rus_windows_hardening_recovery_path}' -ConfigPath '{self.aw_rus_windows_config_path}' }} catch {{ Write-Output ('HARDENING_RECOVERY_WARN: ' + $_.Exception.Message) }} }}\n"
            "Start-Sleep -Seconds 2\n"
            "schtasks /Run /TN 'ActivityWatch Recovery' | Out-Null\n"
            "Start-Sleep -Seconds 2\n"
            "Get-ScheduledTask -ErrorAction SilentlyContinue |\n"
            "  Where-Object { $_.TaskName -like 'ActivityWatch Launch *' } |\n"
            "  ForEach-Object { try { Start-ScheduledTask -TaskName $_.TaskName -ErrorAction Stop } catch {} }\n"
            "Write-Output ('POLICY_SYNC_OK ' + $policyPath)\n"
        )
        try:
            rc, out = self._run_aw_rus_windows_powershell(script, timeout_sec=240)
            if rc != 0:
                tail = (out or "").strip().splitlines()[-1:] or [f"rc={rc}"]
                report.append(f"- windows-policy-sync: FAIL ({tail[0]})")
                return False, report
            report.append("- windows-policy-sync: OK")
            return True, report
        except Exception as exc:
            report.append(f"- windows-policy-sync: FAIL ({exc})")
            return False, report

    def _aw_dlp_policy_mode_text(self) -> str:
        active = self._aw_dlp_policy_request("GET", "/dlp/policies/active", timeout_sec=15)
        policy = active.get("policy") or {}
        decision = self._aw_dlp_policy_decision(policy if isinstance(policy, dict) else {})
        if isinstance(decision, dict):
            mode = str(decision.get("current_mode") or "unknown")
            details = [
                f"- {item.get('name')}: block={item.get('blocked', 0)}/{item.get('total', 0)}"
                for item in (decision.get("groups") or [])
                if isinstance(item, dict)
            ]
        else:
            mode = self._aw_dlp_mode_from_policy(policy if isinstance(policy, dict) else {})
            details = []
            for group_name, rules in self._aw_dlp_toggle_rule_groups(policy if isinstance(policy, dict) else {}):
                block_count = 0
                total_count = 0
                for rule in rules:
                    if not isinstance(rule, dict) or rule.get("enabled") is False:
                        continue
                    total_count += 1
                    if str(rule.get("action") or "").strip().lower() == "block":
                        block_count += 1
                details.append(f"- {group_name}: block={block_count}/{total_count}")
        lines = [
            "DLP режим:",
            f"- policy: {active.get('name') or '-'} (id={active.get('policyId') or '-'})",
            f"- mode: {mode}",
            f"- version: {active.get('version') or '-'}",
            "- web rules не блокируют доступ и остаются incident/alert-only.",
        ]
        lines.extend(details or ["- endpoint rules: n/a"])
        return "\n".join(lines)

    def _aw_dlp_policy_toggle_text(self) -> str:
        active = self._aw_dlp_policy_request("GET", "/dlp/policies/active", timeout_sec=20)
        policy_id = int(active.get("policyId") or 0)
        if policy_id <= 0:
            raise RuntimeError("DLP policy engine returned invalid policyId")
        policy = active.get("policy") or {}
        if not isinstance(policy, dict):
            raise RuntimeError("Active DLP policy payload is invalid")

        decision = self._aw_dlp_policy_decision(policy, target_mode="toggle")
        if isinstance(decision, dict):
            current_mode = str(decision.get("current_mode") or "unknown")
            target_mode = str(decision.get("target_mode") or "")
            updated_policy = decision.get("updated_policy")
            changed_count = int(decision.get("changed_count") or 0)
            changed_rules = [
                str(item)
                for item in (decision.get("changed_rules") or [])
                if str(item)
            ]
            if target_mode not in {"monitor", "enforce"} or not isinstance(updated_policy, dict):
                raise RuntimeError("Rust DLP policy decision returned invalid toggle plan")
        else:
            current_mode = self._aw_dlp_mode_from_policy(policy)
            target_mode = "monitor" if current_mode in {"enforce", "mixed"} else "enforce"
            updated_policy, changed_count, changed_rules = self._aw_dlp_policy_for_mode(policy, target_mode)
        if changed_count == 0:
            return (
                "DLP режим не изменён.\n"
                f"- current_mode: {current_mode}\n"
                "- Подходящих endpoint/email правил для переключения не найдено."
            )

        updated = self._aw_dlp_policy_request(
            "PUT",
            f"/dlp/policies/{policy_id}",
            payload={
                "policy": updated_policy,
                "activate": True,
                "actor": self.aw_dlp_policy_actor,
            },
            timeout_sec=30,
        )
        item = updated.get("item") or {}
        sync_ok, sync_lines = self._aw_rus_windows_sync_dlp_policy(updated_policy)
        lines = [
            "DLP режим переключён.",
            f"- policy: {active.get('name') or '-'} (id={policy_id})",
            f"- mode: {current_mode} -> {target_mode}",
            f"- version: {active.get('version') or '-'} -> {item.get('current_version') or '-'}",
            f"- changed_rules: {changed_count}",
            "- scope: clipboard/usb/print/email; web rules не переводятся в block этим toggle.",
        ]
        if changed_rules:
            lines.append("- sample changes:")
            lines.extend(f"  {entry}" for entry in changed_rules[:6])
        lines.extend(sync_lines)
        lines.append(f"- menu button: {self._aw_dlp_toggle_button_text(current_mode=target_mode)}")
        if not sync_ok:
            lines.append("- note: server-side policy уже активирована; endpoint agents подтянут её сами при следующем refresh.")
        return "\n".join(lines)

    def _run_operator_action(self, action: str) -> str:
        requested_action = action
        decision = self._operator_action_decision(requested_action)
        if isinstance(decision, dict):
            if not bool(decision.get("allowed")):
                return str(decision.get("message") or "Неизвестное действие.")
            action = str(decision.get("canonical_action") or requested_action).strip().lower()
            if action and action != requested_action.strip().lower():
                self._log(
                    "INFO",
                    f"Operator action routed by Rust: {requested_action!r} -> {action!r}",
                )
        else:
            action = requested_action.strip().lower()
        if action == "check":
            rc, out, started = self._run_check_script_once(timeout_sec=240)
            if not started:
                return "/run check skipped: previous check is still running"
            summary = "\n".join(out.splitlines()[-12:])
            return f"/run check rc={rc}\n{summary}"
        if action in ("aw-dlp-check", "awrus-dlp-check"):
            return self._aw_rus_dlp_check_and_heal_text()
        if action in ("dlp-mode", "dlp-policy-mode"):
            try:
                return self._aw_dlp_policy_mode_text()
            except Exception as exc:
                return f"DLP режим: status unavailable ({exc})"
        if action in ("dlp-mode-toggle", "dlp-policy-toggle"):
            try:
                return self._aw_dlp_policy_toggle_text()
            except Exception as exc:
                return f"DLP режим: переключение не выполнено ({exc})"
        if action == "heal":
            ok = self._attempt_autoheal(force=True)
            return f"/run heal result={'ok' if ok else 'failed'}"
        if action in ("ai", "support", "techsupport", "техподдержка", "тех.поддержка"):
            ok = self._escalate_to_ai()
            return f"/run support result={'ok' if ok else 'failed'}"
        if action == "fallback":
            ok = self._run_server_fallback()
            return f"/run fallback result={'ok' if ok else 'failed'}"
        if action == "updates-check":
            rc, out = self._run_shell(f"{self.updates_script} check", timeout_sec=1800)
            summary = self._updates_summary_text()
            return f"/run updates-check rc={rc}\n{summary}\n{out[-1200:]}"
        if action == "updates-install-request":
            self.state.pending_update_install_confirm = True
            self.state.save()
            return (
                "Подтвердите установку критичных и важных обновлений кнопкой "
                f"\"{self.BTN_UPD_INSTALL_CONFIRM}\".\n"
                "Автоматическая установка отключена; действие только ручное."
            )
        if action == "updates-install-confirm":
            if not self.state.pending_update_install_confirm:
                return (
                    "Нет ожидающего запроса на установку. "
                    f"Сначала нажмите \"{self.BTN_UPD_INSTALL}\"."
                )
            self.state.pending_update_install_confirm = False
            self.state.save()
            rc, out = self._run_shell(f"{self.updates_script} apply", timeout_sec=7200)
            rollback_items = self._rollback_pending_count()
            if rc == 0 and rollback_items == 0:
                return (
                    "Критичные и важные обновления установлены успешно.\n"
                    f"{out[-1800:]}"
                )
            self.state.pending_rollback_confirm = rollback_items > 0
            self.state.save()
            if rollback_items > 0:
                return (
                    "Установка обновлений завершилась с проблемами, система не полностью восстановилась.\n"
                    f"Для отката подтвердите кнопкой \"{self.BTN_UPD_ROLLBACK_CONFIRM}\".\n"
                    f"pending_rollback_items={rollback_items}\n{out[-1800:]}"
                )
            return f"Установка критичных и важных обновлений завершилась с ошибкой.\n{out[-1800:]}"
        if action == "updates-rollback-confirm":
            if not self.state.pending_rollback_confirm:
                return (
                    "Нет ожидающего отката. "
                    "Откат доступен только после неуспешного ручного обновления."
                )
            self.state.pending_rollback_confirm = False
            self.state.save()
            rc, out = self._run_shell(f"{self.updates_script} rollback", timeout_sec=5400)
            return f"Откат выполнен (rc={rc}).\n{out[-1800:]}"
        return "Неизвестное действие."

    def _process_message(self, upd: Dict) -> None:
        msg = upd.get("message") or {}
        chat = msg.get("chat") or {}
        chat_id = int(chat.get("id", 0))
        if chat_id not in self.allowed_chats:
            return

        text = (msg.get("text") or "").strip()
        self._log("INFO", f"Incoming Telegram message chat_id={chat_id}: {text!r}")
        self._expire_pending_pfsense_change_if_needed()
        self._expire_pending_openvpn_config_if_needed()
        self._expire_pending_proxmox_selection_if_needed()
        self._expire_pending_proxmox_restore_if_needed()
        self.state.last_operator_message_ts = int(time.time())
        self.state.save()

        if text.startswith("/start") or text.startswith("/help") or text == self.BTN_HELP:
            self._send_menu(chat_id, self._cmd_help())
            return
        if text.startswith("/status") or text == self.BTN_STATUS:
            self._send_text_with_menu(chat_id, self._cmd_status())
            return
        if text.startswith("/check") or text == self.BTN_CHECK:
            self._send_text_with_menu(chat_id, self._run_operator_action("check"))
            return
        if text.startswith("/aw_dlp_check") or text == self.BTN_AW_DLP_CHECK:
            self._send_text_with_menu(chat_id, self._run_operator_action("aw-dlp-check"))
            return
        if text.startswith("/dlp_mode_toggle") or self._is_dlp_toggle_button(text):
            self._send_text_with_menu(chat_id, self._run_operator_action("dlp-mode-toggle"))
            return
        if text.startswith("/dlp_mode"):
            self._send_text_with_menu(chat_id, self._run_operator_action("dlp-mode"))
            return
        if self._is_dfir_button(text):
            self._send_text_with_menu(chat_id, self._aw_rus_hayabusa_usage_text())
            return
        if text.strip() == "/aw_dfir":
            self._send_text_with_menu(chat_id, self._aw_rus_hayabusa_usage_text())
            return
        if text.startswith("/aw_dfir "):
            parts = text.split()
            if len(parts) < 3:
                self._send_text(chat_id, self._aw_rus_hayabusa_usage_text())
                return
            package_path = parts[1]
            host = parts[2]
            case_id = None
            mode = "incident"
            if len(parts) >= 4 and parts[3].isdigit():
                case_id = int(parts[3])
                if len(parts) >= 5:
                    mode = parts[4]
            elif len(parts) >= 4:
                mode = parts[3]
            self._send_text(chat_id, self._aw_rus_hayabusa_run(package_path=package_path, host=host, case_id=case_id, mode=mode))
            return
        if text.startswith("/heal") or text == self.BTN_HEAL:
            self._send_text(chat_id, self._run_operator_action("heal"))
            return
        if text.startswith("/ack") or text == self.BTN_ACK:
            if self.state.pending_incident:
                self.state.pending_incident.operator_acked = True
                self.state.save()
                self._send_text(chat_id, "Инцидент подтвержден оператором, автоматическая эскалация приостановлена.")
            else:
                self._send_text(chat_id, "Активных инцидентов нет.")
            return
        if text.startswith("/resolve") or text == self.BTN_RESOLVE:
            self.state.pending_incident = None
            self.state.save()
            self._send_text(chat_id, "Инцидент закрыт вручную.")
            return
        if text == self.BTN_AI:
            self._send_text(chat_id, self._run_operator_action("ai"))
            return
        if self._button_matches(text, self.BTN_OVPN_CERTS, self.BTN_OVPN_CERTS_ALIASES):
            self._start_openvpn_cert_check_async(chat_id)
            return
        if text == self.BTN_OVPN_EXPIRING:
            self._start_openvpn_expiry_check_async(chat_id)
            return
        if text == self.BTN_OVPN_CONFIG:
            if not self.openvpn_config_enabled:
                self._send_text(chat_id, "Выпуск OpenVPN конфигов через бота отключён.")
                return
            self._send_menu(
                chat_id,
                "Для выпуска нового OpenVPN конфига отправьте команду `/openvpn_config USERNAME_OR_CN`.",
            )
            return
        if text == self.BTN_OVPN_CONFIG_CONFIRM or text.startswith("/openvpn_config_confirm"):
            self._send_text(chat_id, self._confirm_openvpn_config_stage_one())
            return
        if text == self.BTN_OVPN_CONFIG_CANCEL or text.startswith("/openvpn_config_cancel"):
            self._send_text(chat_id, self._cancel_openvpn_config())
            return
        if text.startswith("/openvpn_config_apply "):
            code = text.split(maxsplit=1)[1].strip()
            self._apply_openvpn_config(chat_id, code)
            return
        if text == self.BTN_PFSENSE_CONFIRM or text.startswith("/pfsense_confirm"):
            self._send_text(chat_id, self._confirm_pfsense_change_stage_one())
            return
        if text == self.BTN_PFSENSE_CANCEL or text.startswith("/pfsense_cancel"):
            self._send_text(chat_id, self._cancel_pfsense_change())
            return
        if text.startswith("/pfsense_apply "):
            code = text.split(maxsplit=1)[1].strip()
            self._send_text(chat_id, self._apply_pfsense_change(code))
            return
        if text == self.BTN_FALLBACK:
            self._send_text(chat_id, self._run_operator_action("fallback"))
            return
        if text == self.BTN_UPD_CHECK or text.startswith("/updates_check"):
            self._start_updates_action_async(chat_id, "updates-check")
            return
        if text.startswith("/openvpn_certs"):
            parts = text.split(maxsplit=1)
            self._start_openvpn_cert_check_async(chat_id, parts[1] if len(parts) > 1 else "")
            return
        if text.startswith("/openvpn_expiring"):
            self._start_openvpn_expiry_check_async(chat_id)
            return
        if text.startswith("/openvpn_config") and text.strip() == "/openvpn_config":
            self._send_text(chat_id, "Использование: `/openvpn_config USERNAME_OR_CN`.")
            return
        if text.startswith("/openvpn_config "):
            if not self.openvpn_config_enabled:
                self._send_text(chat_id, "Выпуск OpenVPN конфигов через бота отключён.")
                return
            common_name = text.split(maxsplit=1)[1].strip()
            self._send_text(chat_id, self._start_openvpn_config_flow(common_name))
            return
        if text == self.BTN_UPD_INSTALL or text.startswith("/updates_install"):
            self._send_text(chat_id, self._run_operator_action("updates-install-request"))
            return
        if text == self.BTN_UPD_INSTALL_CONFIRM or text.startswith("/updates_confirm"):
            self._start_updates_action_async(chat_id, "updates-install-confirm")
            return
        if text == self.BTN_UPD_ROLLBACK_CONFIRM or text.startswith("/rollback_confirm"):
            self._start_updates_action_async(chat_id, "updates-rollback-confirm")
            return
        if text == self.BTN_PM_SNAPSHOT or text.startswith("/proxmox_snapshot_select"):
            self.state.pending_proxmox_selection = PendingProxmoxSelection(
                mode="snapshot",
                created_ts=int(time.time()),
            )
            self.state.save()
            self._send_menu(chat_id, self._proxmox_target_prompt("snapshot"))
            return
        if text == self.BTN_PM_RESTORE or text.startswith("/proxmox_restore_select"):
            self.state.pending_proxmox_selection = PendingProxmoxSelection(
                mode="restore",
                created_ts=int(time.time()),
            )
            self.state.save()
            self._send_menu(chat_id, self._proxmox_target_prompt("restore"))
            return
        if text.startswith("/proxmox_selection_cancel"):
            self._send_text(chat_id, self._cancel_proxmox_selection())
            return
        if text.startswith("/proxmox_restore_cancel"):
            self._send_text(chat_id, self._cancel_proxmox_restore())
            return
        if text.strip() == "/proxmox_snapshot":
            self.state.pending_proxmox_selection = PendingProxmoxSelection(
                mode="snapshot",
                created_ts=int(time.time()),
            )
            self.state.save()
            self._send_menu(chat_id, self._proxmox_target_prompt("snapshot"))
            return
        if text.startswith("/proxmox_snapshot "):
            try:
                self._send_text(chat_id, self._create_manual_proxmox_snapshot(text.split(maxsplit=1)[1].strip()))
            except Exception as exc:
                self._send_text(chat_id, f"Не удалось создать snapshot: {exc}")
            return
        if text.strip() == "/proxmox_restore":
            self.state.pending_proxmox_selection = PendingProxmoxSelection(
                mode="restore",
                created_ts=int(time.time()),
            )
            self.state.save()
            self._send_menu(chat_id, self._proxmox_target_prompt("restore"))
            return
        if text.startswith("/proxmox_restore "):
            try:
                self._send_text(chat_id, self._start_proxmox_restore_flow(text.split(maxsplit=1)[1].strip()))
            except Exception as exc:
                self._send_text(chat_id, f"Не удалось подготовить восстановление: {exc}")
            return
        if text.startswith("/proxmox_restore_apply "):
            code = text.split(maxsplit=1)[1].strip()
            self._send_text(chat_id, self._apply_proxmox_restore(code))
            return
        if text.startswith("/run "):
            action = text.split(maxsplit=1)[1]
            self._send_text(chat_id, self._run_operator_action(action))
            return
        if self._button_matches(text, self.BTN_AI_CHAT, self.BTN_AI_CHAT_ALIASES):
            self._send_menu(chat_id, self._next_ai_chat_intro_text())
            return
        if self.state.pending_proxmox_selection and not text.startswith("/"):
            mode = self.state.pending_proxmox_selection.mode
            self.state.pending_proxmox_selection = None
            self.state.save()
            try:
                if mode == "snapshot":
                    self._send_text(chat_id, self._create_manual_proxmox_snapshot(text))
                else:
                    self._send_text(chat_id, self._start_proxmox_restore_flow(text))
            except Exception as exc:
                self._send_text(chat_id, f"Не удалось обработать выбор узла Proxmox: {exc}")
            return
        if self.pfsense_change_control_enabled and self._looks_like_pfsense_write_request(text):
            self._send_text(chat_id, self._start_pfsense_change_flow(text))
            return
        if self.ai_chat_enabled:
            self._send_text(chat_id, self._run_ai_chat_codex_exec(text))
            return
        self._send_menu(chat_id, "Не понял команду. Используйте кнопки меню.")

    def run(self) -> None:
        next_check_ts = 0
        while True:
            try:
                self._touch_heartbeat()
                updates = self.api.get_updates(self.state.last_update_id + 1, timeout=10)
                for upd in updates:
                    uid = int(upd.get("update_id", 0))
                    if uid > self.state.last_update_id:
                        self.state.last_update_id = uid
                    self._process_message(upd)
                self.state.save()

                now = time.time()
                if now >= next_check_ts:
                    self._handle_check_cycle()
                    if self.openvpn_expiry_warn_enabled and now >= self.next_openvpn_expiry_warn_ts:
                        self._sync_openvpn_expiry_warning()
                        self.next_openvpn_expiry_warn_ts = now + self.openvpn_expiry_warn_interval_sec
                    next_check_ts = now + self.check_interval

                self._evaluate_timeout_escalation()
                time.sleep(1)
            except SystemExit:
                raise
            except Exception as exc:
                self._log("ERROR", f"Main loop error: {exc}\n{traceback.format_exc()}")
                time.sleep(5)


def main() -> int:
    bot = TSJGuardianBot()
    bot.run()
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except SystemExit:
        raise
    except Exception as exc:
        print(f"Fatal: {exc}", file=sys.stderr)
        raise
