from __future__ import annotations

import copy
import hashlib
import importlib
import inspect
import json
import pkgutil
import re
import time
from dataclasses import dataclass
from pathlib import Path
from threading import Lock, Thread
from typing import Any, Callable, Dict, Optional

from armorer import db as armorer_db
from armorer.runtime_capabilities import ensure_llm_guard_runtime_dependency

_DEFAULT_ENABLED_INPUT = {"Secrets", "PromptInjection"}
_DEFAULT_ENABLED_OUTPUT = {"Sensitive"}
_DEFAULT_BLOCK_FLAGS = [
    "Prompt injection",
    "Data exfiltration",
    "Destructive command",
    "Safety bypass",
]

_FLAG_SLUGS = {
    "Prompt injection": "prompt_injection",
    "Data exfiltration": "data_exfiltration",
    "Destructive command": "destructive_command",
    "Safety bypass": "safety_bypass",
    "Sensitive data": "sensitive_data",
    "Suspicious behavior": "suspicious_behavior",
}
_FLAG_LABELS = {v: k for k, v in _FLAG_SLUGS.items()}


@dataclass
class GuardInspection:
    sanitized_text: str
    suspicious: bool
    reasons: list[str]
    confidence: float


class ConversationGuard:
    """Conversation safety layer with optional LLM Guard integration."""

    def __init__(self, config_dir: Path, notifier: Optional[Callable[[str, str], None]] = None):
        self.config_dir = config_dir
        self.notifier = notifier
        self.history_dir = config_dir / "conversation_history"
        self.controls_file = config_dir / "conversation_controls.json"
        self.settings_file = config_dir / "guard_settings.json"
        self.alert_cache_file = config_dir / "guard_alert_cache.json"
        self.history_dir.mkdir(parents=True, exist_ok=True)

        self._llm_guard_available = False
        self._llm_guard_enabled = False
        self._llm_input_scanners: list[Any] = []
        self._llm_output_scanners: list[Any] = []
        self._scan_prompt = None
        self._scan_output = None
        self._input_registry: dict[str, type] = {}
        self._output_registry: dict[str, type] = {}
        self._input_signatures: dict[str, str] = {}
        self._output_signatures: dict[str, str] = {}
        self._guard_settings: dict[str, Any] = {}
        self._scanner_init_errors: list[str] = []
        self._alert_dedupe_window_seconds = 45.0
        self._recent_alerts: dict[str, float] = {}

        self._llm_scanners_ready = False
        self._llm_init_started = False
        self._llm_init_lock = Lock()

        self._setup_llm_guard()
        self._load_guard_settings()
        self._load_alert_cache()

    def _setup_llm_guard(self) -> None:
        if not ensure_llm_guard_runtime_dependency():
            self._llm_guard_available = False
            return
        try:
            from llm_guard import scan_output, scan_prompt

            self._scan_prompt = scan_prompt
            self._scan_output = scan_output
            self._input_registry, self._input_signatures = self._discover_scanners("llm_guard.input_scanners")
            self._output_registry, self._output_signatures = self._discover_scanners("llm_guard.output_scanners")
            self._llm_guard_available = True
        except Exception:
            self._llm_guard_available = False

    def _discover_scanners(self, package_name: str) -> tuple[dict[str, type], dict[str, str]]:
        registry: dict[str, type] = {}
        signatures: dict[str, str] = {}
        package = importlib.import_module(package_name)

        for _, module_name, _ in pkgutil.iter_modules(package.__path__):
            if module_name.startswith("_"):
                continue
            module = importlib.import_module(f"{package_name}.{module_name}")
            for cls_name, cls in inspect.getmembers(module, inspect.isclass):
                if cls.__module__ != module.__name__:
                    continue
                if cls_name in {"Scanner", "MatchType", "MatchingStrategy"}:
                    continue
                if cls_name.startswith("_"):
                    continue
                registry[cls_name] = cls
                try:
                    signatures[cls_name] = str(inspect.signature(cls.__init__))
                except Exception:
                    signatures[cls_name] = "(unknown)"

        return dict(sorted(registry.items())), signatures

    def _default_guard_settings(self) -> dict[str, Any]:
        return {
            "llm_guard_enabled": bool(self._llm_guard_available),
            "alerting_enabled": True,
            "proactive_security_enabled": False,
            "proactive_security_prompted": False,
            "telegram_alert_chat_id": "",
            "telegram_admin_chat_ids": [],
            "telegram_admin_user_ids": [],
            "telegram_allow_unconfigured_admins": False,
            "alert_confidence_threshold": 0.0,
            "alert_flag_thresholds": {},
            "disabled_alert_flags": [],
            "input_block_enabled": True,
            "input_block_confidence_threshold": 0.85,
            "block_on_flags": list(_DEFAULT_BLOCK_FLAGS),
            "input_scanners": {
                name: {"enabled": name in _DEFAULT_ENABLED_INPUT, "kwargs": {}}
                for name in self._input_registry.keys()
            },
            "output_scanners": {
                name: {"enabled": name in _DEFAULT_ENABLED_OUTPUT, "kwargs": {}}
                for name in self._output_registry.keys()
            },
        }

    @staticmethod
    def _normalize_scanner_node(node: Any, default_enabled: bool) -> dict[str, Any]:
        if not isinstance(node, dict):
            node = {}
        enabled = bool(node.get("enabled", default_enabled))
        kwargs = node.get("kwargs", {})
        if not isinstance(kwargs, dict):
            kwargs = {}
        return {"enabled": enabled, "kwargs": kwargs}

    def _normalize_guard_settings(self, raw: Any, existing_file: bool) -> dict[str, Any]:
        base = raw if isinstance(raw, dict) else {}
        llm_guard_enabled = bool(base.get("llm_guard_enabled", self._llm_guard_available))
        alerting_enabled = bool(base.get("alerting_enabled", True))
        proactive_security_enabled = bool(base.get("proactive_security_enabled", False))
        proactive_security_prompted = bool(base.get("proactive_security_prompted", False))
        telegram_alert_chat_id = str(base.get("telegram_alert_chat_id", "") or "").strip()
        raw_admin_chat_ids = base.get("telegram_admin_chat_ids", [])
        if isinstance(raw_admin_chat_ids, str):
            raw_admin_chat_ids = [item.strip() for item in raw_admin_chat_ids.split(",") if item.strip()]
        if not isinstance(raw_admin_chat_ids, list):
            raw_admin_chat_ids = []
        telegram_admin_chat_ids = []
        for value in raw_admin_chat_ids:
            text = str(value or "").strip()
            if text and text not in telegram_admin_chat_ids:
                telegram_admin_chat_ids.append(text)

        raw_admin_user_ids = base.get("telegram_admin_user_ids", [])
        if isinstance(raw_admin_user_ids, str):
            raw_admin_user_ids = [item.strip() for item in raw_admin_user_ids.split(",") if item.strip()]
        if not isinstance(raw_admin_user_ids, list):
            raw_admin_user_ids = []
        telegram_admin_user_ids = []
        for value in raw_admin_user_ids:
            text = str(value or "").strip()
            if text and text not in telegram_admin_user_ids:
                telegram_admin_user_ids.append(text)
        telegram_allow_unconfigured_admins = bool(base.get("telegram_allow_unconfigured_admins", False))
        try:
            alert_confidence_threshold = float(base.get("alert_confidence_threshold", 0.0))
        except Exception:
            alert_confidence_threshold = 0.0
        alert_confidence_threshold = min(max(alert_confidence_threshold, 0.0), 1.0)
        raw_flag_thresholds = base.get("alert_flag_thresholds", {})
        if not isinstance(raw_flag_thresholds, dict):
            raw_flag_thresholds = {}
        alert_flag_thresholds: dict[str, float] = {}
        for raw_key, raw_value in raw_flag_thresholds.items():
            slug = self.alert_flag_slug(raw_key)
            if not slug:
                continue
            try:
                value = float(raw_value)
            except Exception:
                continue
            alert_flag_thresholds[slug] = min(max(value, 0.0), 1.0)
        raw_disabled_flags = base.get("disabled_alert_flags", [])
        if not isinstance(raw_disabled_flags, list):
            raw_disabled_flags = []
        disabled_alert_flags = []
        for raw_flag in raw_disabled_flags:
            slug = self.alert_flag_slug(raw_flag)
            if slug and slug not in disabled_alert_flags:
                disabled_alert_flags.append(slug)
        input_block_enabled = bool(base.get("input_block_enabled", True))
        try:
            input_block_confidence_threshold = float(base.get("input_block_confidence_threshold", 0.85))
        except Exception:
            input_block_confidence_threshold = 0.85
        input_block_confidence_threshold = min(max(input_block_confidence_threshold, 0.0), 1.0)
        block_on_flags = base.get("block_on_flags", [])
        if not isinstance(block_on_flags, list):
            block_on_flags = []
        block_on_flags = [str(x).strip() for x in block_on_flags if str(x).strip()]
        if not block_on_flags:
            block_on_flags = list(_DEFAULT_BLOCK_FLAGS)

        in_cfg_raw = base.get("input_scanners", {})
        out_cfg_raw = base.get("output_scanners", {})
        in_cfg_raw = in_cfg_raw if isinstance(in_cfg_raw, dict) else {}
        out_cfg_raw = out_cfg_raw if isinstance(out_cfg_raw, dict) else {}

        normalized_in: dict[str, Any] = {}
        for name in self._input_registry.keys():
            default_enabled = name in _DEFAULT_ENABLED_INPUT if not existing_file else False
            normalized_in[name] = self._normalize_scanner_node(in_cfg_raw.get(name), default_enabled)

        normalized_out: dict[str, Any] = {}
        for name in self._output_registry.keys():
            default_enabled = name in _DEFAULT_ENABLED_OUTPUT if not existing_file else False
            normalized_out[name] = self._normalize_scanner_node(out_cfg_raw.get(name), default_enabled)

        return {
            "llm_guard_enabled": llm_guard_enabled,
            "alerting_enabled": alerting_enabled,
            "proactive_security_enabled": proactive_security_enabled,
            "proactive_security_prompted": proactive_security_prompted,
            "telegram_alert_chat_id": telegram_alert_chat_id,
            "telegram_admin_chat_ids": telegram_admin_chat_ids,
            "telegram_admin_user_ids": telegram_admin_user_ids,
            "telegram_allow_unconfigured_admins": telegram_allow_unconfigured_admins,
            "alert_confidence_threshold": alert_confidence_threshold,
            "alert_flag_thresholds": alert_flag_thresholds,
            "disabled_alert_flags": disabled_alert_flags,
            "input_block_enabled": input_block_enabled,
            "input_block_confidence_threshold": input_block_confidence_threshold,
            "block_on_flags": block_on_flags,
            "input_scanners": normalized_in,
            "output_scanners": normalized_out,
        }

    @staticmethod
    def alert_flag_slug(flag: str) -> str:
        text = str(flag or "").strip()
        if not text:
            return ""
        return _FLAG_SLUGS.get(text, _FLAG_SLUGS.get(text.title(), text.lower().replace(" ", "_")))

    @staticmethod
    def alert_flag_label(flag_slug: str) -> str:
        slug = str(flag_slug or "").strip().lower()
        if not slug:
            return "Suspicious behavior"
        return _FLAG_LABELS.get(slug, slug.replace("_", " ").title())

    def should_alert(self, inspection: GuardInspection) -> tuple[bool, list[str], list[str]]:
        if not inspection.suspicious:
            return False, [], []
        settings = self._guard_settings or {}
        if not bool(settings.get("alerting_enabled", True)):
            return False, [], []
        flags = self.summarize_flags(inspection.reasons)
        if not flags:
            flags = ["Suspicious behavior"]
        disabled = {self.alert_flag_slug(flag) for flag in settings.get("disabled_alert_flags", [])}
        active_flags = [flag for flag in flags if self.alert_flag_slug(flag) not in disabled]
        if not active_flags:
            return False, flags, []
        base_threshold = float(settings.get("alert_confidence_threshold", 0.0) or 0.0)
        per_flag = settings.get("alert_flag_thresholds", {})
        for flag in active_flags:
            slug = self.alert_flag_slug(flag)
            threshold = max(base_threshold, float(per_flag.get(slug, 0.0) or 0.0))
            if threshold <= 0.0 or inspection.confidence >= threshold:
                return True, flags, active_flags
        return False, flags, active_flags

    def increase_alert_threshold(self, flag_slug: str, step: float = 0.15) -> float:
        slug = self.alert_flag_slug(flag_slug)
        settings = self.get_guard_settings()
        thresholds = settings.get("alert_flag_thresholds", {})
        if not isinstance(thresholds, dict):
            thresholds = {}
        current = float(thresholds.get(slug, 0.0) or 0.0)
        thresholds[slug] = min(max(current + float(step), 0.0), 1.0)
        settings["alert_flag_thresholds"] = thresholds
        self.save_guard_settings(settings)
        return float(thresholds[slug])

    def disable_alert_flag(self, flag_slug: str) -> None:
        slug = self.alert_flag_slug(flag_slug)
        settings = self.get_guard_settings()
        disabled = settings.get("disabled_alert_flags", [])
        if not isinstance(disabled, list):
            disabled = []
        if slug and slug not in disabled:
            disabled.append(slug)
        settings["disabled_alert_flags"] = disabled
        self.save_guard_settings(settings)

    def _save_guard_settings(self, data: dict[str, Any]) -> None:
        self.settings_file.parent.mkdir(parents=True, exist_ok=True)
        with open(self.settings_file, "w") as f:
            json.dump(data, f, indent=2)

    def _load_alert_cache(self) -> None:
        try:
            if not self.alert_cache_file.exists():
                self._recent_alerts = {}
                return
            raw = json.loads(self.alert_cache_file.read_text())
            if not isinstance(raw, dict):
                self._recent_alerts = {}
                return
            now = time.time()
            self._recent_alerts = {}
            for key, value in raw.items():
                try:
                    ts = float(value)
                except Exception:
                    continue
                if now - ts <= (self._alert_dedupe_window_seconds * 2):
                    self._recent_alerts[str(key)] = ts
        except Exception:
            self._recent_alerts = {}

    def _save_alert_cache(self) -> None:
        try:
            self.alert_cache_file.parent.mkdir(parents=True, exist_ok=True)
            self.alert_cache_file.write_text(json.dumps(self._recent_alerts, indent=2, sort_keys=True))
        except Exception:
            pass

    def _prune_alert_cache(self) -> None:
        cutoff = time.time() - (self._alert_dedupe_window_seconds * 2)
        self._recent_alerts = {
            key: ts
            for key, ts in self._recent_alerts.items()
            if isinstance(ts, (int, float)) and ts >= cutoff
        }

    @staticmethod
    def _alert_source_family(source: str) -> tuple[str, str]:
        text = str(source or "").strip()
        if text.startswith("file:"):
            name = text.split(":", 1)[1].strip().lower()
            if name == "sessions.json" or name.endswith(".jsonl"):
                return "session_activity", "Session activity"
            if name.endswith(".log"):
                return "runtime_logs", "Runtime logs"
            return f"file:{name}", name or "File activity"
        if text.startswith("otel:"):
            return "runtime_events", "Runtime events"
        return text.lower() or "unknown", text or "Unknown"

    @staticmethod
    def _extract_incident_marker(snippet: str) -> str:
        text = str(snippet or "")
        match = re.search(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", text, re.IGNORECASE)
        if match:
            return match.group(0).lower()
        return ""

    def _build_alert_fingerprint(
        self,
        conversation_id: str,
        flags: list[str],
        source: str,
        snippet: str,
    ) -> str:
        source_family, _ = self._alert_source_family(source)
        incident_marker = self._extract_incident_marker(snippet)
        if incident_marker:
            raw = f"{conversation_id}|{source_family}|{incident_marker}"
        else:
            normalized_flags = "|".join(
                sorted(
                    slug
                    for slug in (self.alert_flag_slug(flag) for flag in flags)
                    if slug
                )
            )
            raw = f"{conversation_id}|{normalized_flags}|{source_family}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def _should_suppress_alert(
        self,
        conversation_id: str,
        flags: list[str],
        source: str,
        snippet: str,
    ) -> tuple[bool, str]:
        self._prune_alert_cache()
        fingerprint = self._build_alert_fingerprint(conversation_id, flags, source, snippet)
        now = time.time()
        previous = float(self._recent_alerts.get(fingerprint, 0.0) or 0.0)
        if previous and (now - previous) < self._alert_dedupe_window_seconds:
            return True, fingerprint
        self._recent_alerts[fingerprint] = now
        self._save_alert_cache()
        return False, fingerprint

    def _load_guard_settings(self) -> None:
        existing_file = self.settings_file.exists()
        raw: Any = None
        if existing_file:
            try:
                raw = json.loads(self.settings_file.read_text())
            except Exception:
                raw = None

        normalized = self._normalize_guard_settings(raw, existing_file=existing_file)
        self._guard_settings = normalized
        self._save_guard_settings(normalized)

        self._llm_guard_enabled = self._llm_guard_available and bool(normalized.get("llm_guard_enabled", True))
        self._llm_scanners_ready = False
        self._llm_init_started = False

    def get_guard_settings(self) -> dict[str, Any]:
        return copy.deepcopy(self._guard_settings)

    def get_proactive_security_settings(self) -> dict[str, Any]:
        settings = self.get_guard_settings()
        return {
            "proactive_security_enabled": bool(settings.get("proactive_security_enabled", False)),
            "proactive_security_prompted": bool(settings.get("proactive_security_prompted", False)),
            "telegram_alert_chat_id": str(settings.get("telegram_alert_chat_id", "") or "").strip(),
            "telegram_admin_chat_ids": list(settings.get("telegram_admin_chat_ids", []) or []),
            "telegram_admin_user_ids": list(settings.get("telegram_admin_user_ids", []) or []),
            "telegram_allow_unconfigured_admins": bool(settings.get("telegram_allow_unconfigured_admins", False)),
        }

    def save_proactive_security_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
        settings = self.get_guard_settings()
        if "proactive_security_enabled" in updates:
            settings["proactive_security_enabled"] = bool(updates.get("proactive_security_enabled", False))
        if "proactive_security_prompted" in updates:
            settings["proactive_security_prompted"] = bool(updates.get("proactive_security_prompted", False))
        if "telegram_alert_chat_id" in updates:
            settings["telegram_alert_chat_id"] = str(updates.get("telegram_alert_chat_id", "") or "").strip()
        if "telegram_admin_chat_ids" in updates:
            raw = updates.get("telegram_admin_chat_ids", [])
            if isinstance(raw, str):
                raw = [item.strip() for item in raw.split(",") if item.strip()]
            settings["telegram_admin_chat_ids"] = [str(item).strip() for item in raw if str(item).strip()]
        if "telegram_admin_user_ids" in updates:
            raw = updates.get("telegram_admin_user_ids", [])
            if isinstance(raw, str):
                raw = [item.strip() for item in raw.split(",") if item.strip()]
            settings["telegram_admin_user_ids"] = [str(item).strip() for item in raw if str(item).strip()]
        if "telegram_allow_unconfigured_admins" in updates:
            settings["telegram_allow_unconfigured_admins"] = bool(updates.get("telegram_allow_unconfigured_admins", False))
        self.save_guard_settings(settings)
        return self.get_proactive_security_settings()

    def save_guard_settings(self, settings: dict[str, Any]) -> None:
        normalized = self._normalize_guard_settings(settings, existing_file=True)
        self._guard_settings = normalized
        self._save_guard_settings(normalized)
        self._llm_guard_enabled = self._llm_guard_available and bool(normalized.get("llm_guard_enabled", True))
        self._llm_input_scanners = []
        self._llm_output_scanners = []
        self._llm_scanners_ready = False
        self._llm_init_started = False

    def available_scanners(self, scanner_type: str) -> dict[str, str]:
        if scanner_type == "input":
            return dict(self._input_signatures)
        if scanner_type == "output":
            return dict(self._output_signatures)
        raise ValueError("scanner_type must be 'input' or 'output'")

    def _ensure_llm_scanners(self) -> None:
        if not self._llm_guard_enabled:
            return
        if self._llm_scanners_ready:
            return
        with self._llm_init_lock:
            if self._llm_init_started:
                return
            self._llm_init_started = True

        input_cfg = self._guard_settings.get("input_scanners", {})
        output_cfg = self._guard_settings.get("output_scanners", {})

        def _init_scanners() -> None:
            try:
                input_scanners: list[Any] = []
                output_scanners: list[Any] = []
                errors: list[str] = []

                for name, cls in self._input_registry.items():
                    cfg = input_cfg.get(name, {})
                    if not isinstance(cfg, dict) or not cfg.get("enabled"):
                        continue
                    kwargs = cfg.get("kwargs", {})
                    if not isinstance(kwargs, dict):
                        kwargs = {}
                    try:
                        input_scanners.append(cls(**kwargs))
                    except Exception as e:
                        errors.append(f"input:{name}:{e}")

                for name, cls in self._output_registry.items():
                    cfg = output_cfg.get(name, {})
                    if not isinstance(cfg, dict) or not cfg.get("enabled"):
                        continue
                    kwargs = cfg.get("kwargs", {})
                    if not isinstance(kwargs, dict):
                        kwargs = {}
                    try:
                        output_scanners.append(cls(**kwargs))
                    except Exception as e:
                        errors.append(f"output:{name}:{e}")

                self._llm_input_scanners = input_scanners
                self._llm_output_scanners = output_scanners
                self._scanner_init_errors = errors
                self._llm_scanners_ready = bool(input_scanners or output_scanners)
            except Exception:
                self._llm_input_scanners = []
                self._llm_output_scanners = []
                self._scanner_init_errors = ["fatal:scanner_init"]
                self._llm_scanners_ready = False

        Thread(target=_init_scanners, daemon=True).start()

    def llm_guard_status(self) -> dict[str, Any]:
        return {
            "available": self._llm_guard_available,
            "enabled": self._llm_guard_enabled,
            "scanners_ready": self._llm_scanners_ready,
            "input_loaded": [type(s).__name__ for s in self._llm_input_scanners],
            "output_loaded": [type(s).__name__ for s in self._llm_output_scanners],
            "init_errors": list(self._scanner_init_errors),
            "settings_file": str(self.settings_file),
        }

    def conversation_id(self, source: str, metadata: Optional[Dict[str, Any]]) -> str:
        meta = metadata or {}
        source_name = str(source or "cli")
        channel_id = str(meta.get("channel_id") or "").strip()
        user_id = str(meta.get("user_id") or "").strip()
        if source_name in {"telegram", "signal"} and channel_id and user_id:
            return f"{source_name}:{channel_id}:{user_id}"
        channel = channel_id or user_id or "default"
        return f"{source_name}:{channel}"

    def inspect_input(self, text: str) -> GuardInspection:
        return self._inspect_text(text=text, is_output=False)

    def inspect_output(self, text: str) -> GuardInspection:
        return self._inspect_text(text=text, is_output=True)

    def inspect_operator(self, text: str) -> GuardInspection:
        """Inspection for operator-to-Armorer chat.

        Armorer monitors managed agents (runtime logs/OTEL) via LLM Guard, but operator chat with Armorer
        should not be blocked or escalated by the guard. We still apply regex redaction so secrets are not
        persisted in the conversation history.
        """
        original = str(text or "")
        sanitized = self._regex_redact(original)
        return GuardInspection(
            sanitized_text=sanitized,
            suspicious=False,
            reasons=[],
            confidence=0.0,
        )

    def _inspect_text(self, text: str, is_output: bool) -> GuardInspection:
        original = str(text or "")
        suspicious = False
        reasons: list[str] = []
        confidence = 0.0
        sanitized = self._regex_redact(original)

        if self._llm_guard_enabled:
            try:
                self._ensure_llm_scanners()
                if not self._llm_scanners_ready:
                    raise RuntimeError("llm_guard scanners not ready")
                if is_output and self._scan_output is not None:
                    sanitized_llm, valid, score = self._scan_output(self._llm_output_scanners, "", original)
                elif not is_output and self._scan_prompt is not None:
                    sanitized_llm, valid, score = self._scan_prompt(self._llm_input_scanners, original)
                else:
                    sanitized_llm, valid, score = original, {}, {}
                if sanitized_llm:
                    sanitized = str(sanitized_llm)
                suspicious, reasons, confidence = self._summarize_llm_results(valid, score)
            except Exception:
                pass

        heuristic_reasons = self._heuristic_reasons(original)
        if heuristic_reasons:
            suspicious = True
            reasons = sorted(set(reasons + heuristic_reasons))

        return GuardInspection(
            sanitized_text=sanitized,
            suspicious=suspicious,
            reasons=reasons,
            confidence=round(confidence, 2),
        )

    def _summarize_llm_results(self, valid: Any, score: Any) -> tuple[bool, list[str], float]:
        suspicious = False
        reasons: list[str] = []
        confidence = 0.0

        if isinstance(valid, dict):
            for scanner_name, is_valid in valid.items():
                if not bool(is_valid):
                    suspicious = True
                    reasons.append(f"llm_guard:{scanner_name}")

        if isinstance(score, dict):
            for scanner_name, scanner_score in score.items():
                try:
                    s = float(scanner_score)
                except Exception:
                    continue
                confidence = max(confidence, s)
                if s >= 0.7:
                    suspicious = True
                    reasons.append(f"llm_guard_high_score:{scanner_name}:{s:.2f}")

        return suspicious, sorted(set(reasons)), confidence

    def _heuristic_reasons(self, text: str) -> list[str]:
        t = text.lower()
        reasons: list[str] = []
        regex_patterns = [
            (r"ignore\s+(all\s+)?previous\s+(instr\w+|insrtuction\w*)", "ignore previous instructions"),
            (r"system\s+prompt", "system prompt"),
            (r"developer\s+(message|prompt|instructions?)", "developer message"),
            (r"reveal\s+(all\s+)?secret\w*", "reveal secrets"),
            (r"\bapi\s+key(s)?\b", "api key"),
            (r"\bpass(word|codes?)\b", "password"),
            (r"\bcredential(s)?\b", "credentials"),
            (r"\bsecret(s)?\b", "secrets"),
            (r"rm\s+-rf\s+/", "rm -rf /"),
            (r"\bexfiltrat\w*\b", "exfiltrate"),
            (r"bypass\s+safety", "bypass safety"),
            (r"disable\s+security", "disable security"),
        ]
        for pattern, label in regex_patterns:
            if re.search(pattern, t):
                reasons.append(f"heuristic:{label}")
        return reasons

    def should_block_input(self, inspection: GuardInspection) -> tuple[bool, str]:
        settings = self._guard_settings or {}
        if not bool(settings.get("input_block_enabled", True)):
            return False, ""
        flags = set(self.summarize_flags(inspection.reasons))
        block_flags = {str(x).strip() for x in settings.get("block_on_flags", []) if str(x).strip()}
        if flags.intersection(block_flags):
            matched = ", ".join(sorted(flags.intersection(block_flags)))
            return True, f"Blocked by guard policy due to high-risk flags: {matched}."
        threshold = float(settings.get("input_block_confidence_threshold", 0.85) or 0.85)
        if inspection.confidence >= threshold:
            return True, f"Blocked by guard policy due to confidence score {inspection.confidence:.2f} >= {threshold:.2f}."
        return False, ""

    def summarize_flags(self, reasons: list[str]) -> list[str]:
        """Map low-level scanner reasons to human-readable categories."""
        flags: set[str] = set()
        for r in reasons or []:
            rr = str(r).lower()
            if "promptinjection" in rr or "prompt_injection" in rr:
                flags.add("Prompt injection")
            if "secrets" in rr or "sensitive" in rr:
                flags.add("Sensitive data")
            if "heuristic:ignore previous instructions" in rr or "heuristic:system prompt" in rr or "heuristic:developer message" in rr:
                flags.add("Prompt injection")
            if "heuristic:reveal secrets" in rr or "heuristic:api key" in rr or "heuristic:password" in rr or "heuristic:credentials" in rr:
                flags.add("Sensitive data")
            if "heuristic:exfiltrate" in rr:
                flags.add("Data exfiltration")
            if "heuristic:rm -rf /" in rr:
                flags.add("Destructive command")
            if "heuristic:bypass safety" in rr or "heuristic:disable security" in rr:
                flags.add("Safety bypass")

        if not flags and reasons:
            flags.add("Suspicious behavior")
        return sorted(flags)

    def _regex_redact(self, text: str) -> str:
        redacted = str(text or "")
        patterns = [
            (r"(?i)\b(sk-[a-z0-9_-]{20,})\b", "[REDACTED_OPENAI_KEY]"),
            (r"(?i)\b(AIza[0-9A-Za-z\-_]{20,})\b", "[REDACTED_GEMINI_KEY]"),
            (r"(?i)\b(bearer\s+[a-z0-9\-\._~\+\/]+=*)\b", "[REDACTED_BEARER]"),
            (r"(?i)\b([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})\b", "[REDACTED_EMAIL]"),
            (r"(?i)\b(\+?[0-9][0-9\-\(\) ]{8,}[0-9])\b", "[REDACTED_PHONE]"),
            (r"(?i)\b(eyJ[a-zA-Z0-9_\-]{20,}\.[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,})\b", "[REDACTED_JWT]"),
        ]
        for pattern, repl in patterns:
            redacted = re.sub(pattern, repl, redacted)
        return redacted

    def _extract_text_from_value(self, value: Any) -> str:
        if isinstance(value, str):
            return value
        if isinstance(value, list):
            parts = [self._extract_text_from_value(item) for item in value]
            return "\n".join(part for part in parts if part.strip())
        if isinstance(value, dict):
            if value.get("type") == "text" and isinstance(value.get("text"), str):
                return value["text"]
            for key in ("text", "content", "message", "prompt", "query", "input", "final", "output"):
                nested = value.get(key)
                text = self._extract_text_from_value(nested)
                if text.strip():
                    return text
        return ""

    def _clean_alert_display_text(self, text: str, kind: str) -> str:
        cleaned = str(text or "")
        if not cleaned.strip():
            return ""
        if kind == "input":
            cleaned = re.sub(
                r"(?is)Conversation info \(untrusted metadata\):\s*```json\s*.*?```",
                "",
                cleaned,
            )
            cleaned = re.sub(
                r"(?is)Sender \(untrusted metadata\):\s*```json\s*.*?```",
                "",
                cleaned,
            )
        if kind == "output":
            cleaned = re.sub(r"(?is)</?final>", "", cleaned)
        cleaned = cleaned.strip()
        cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
        return cleaned

    def _extract_io_from_snippet(self, snippet: str) -> tuple[str, str]:
        text = str(snippet or "")
        if not text.strip():
            return "<empty>", ""
        interesting_terms = [
            "ignore previous instructions",
            "system prompt",
            "developer message",
            "reveal secrets",
            "api key",
            "exfiltrate",
            "disable security",
            "bypass safety",
            "rm -rf /",
        ]
        lines = [line.strip() for line in text.splitlines() if line.strip()]
        input_candidates: list[tuple[int, int, str]] = []
        output_candidates: list[tuple[int, int, str]] = []

        def _push_candidate(
            bucket: list[tuple[int, int, str]],
            candidate_text: str,
            role_hint: str = "",
            kind_hint: str = "",
            index: int = 0,
        ) -> None:
            value = str(candidate_text or "").strip()
            if not value:
                return
            lower = value.lower()
            score = 0
            if any(term in lower for term in interesting_terms):
                score += 100
            hints = f"{role_hint} {kind_hint}".lower()
            if any(term in hints for term in ("user", "input", "prompt", "request", "human")):
                score += 50
            if any(term in hints for term in ("assistant", "output", "response", "final", "model")):
                score -= 25
            bucket.append((score, index, value))

        for index, line in enumerate(lines):
            parsed = None
            if line.startswith("{") and line.endswith("}"):
                try:
                    parsed = json.loads(line)
                except Exception:
                    parsed = None
            if isinstance(parsed, dict):
                payload = parsed.get("message") if isinstance(parsed.get("message"), dict) else parsed
                role_hint = " ".join(
                    str(payload.get(key, "") or "")
                    for key in ("role", "sender", "actor", "direction", "kind", "type")
                    if isinstance(payload, dict)
                ).strip()
                input_text = ""
                output_text = ""
                if isinstance(payload, dict):
                    for field in ("content", "text", "prompt", "query", "input"):
                        candidate = self._extract_text_from_value(payload.get(field))
                        if candidate.strip():
                            input_text = candidate
                            break
                    for field in ("final", "output"):
                        candidate = self._extract_text_from_value(payload.get(field))
                        if candidate.strip():
                            output_text = candidate
                            break
                    if not output_text and role_hint.lower().find("assistant") >= 0:
                        output_text = self._extract_text_from_value(payload.get("content"))
                role_lower = role_hint.lower()
                if input_text.strip():
                    if "assistant" in role_lower:
                        _push_candidate(output_candidates, input_text, role_hint=role_hint, kind_hint="assistant", index=index)
                    else:
                        _push_candidate(input_candidates, input_text, role_hint=role_hint, kind_hint="input", index=index)
                if output_text.strip():
                    _push_candidate(output_candidates, output_text, role_hint=role_hint or "assistant", kind_hint="output", index=index)
                continue
            _push_candidate(input_candidates, line, index=index)

        if input_candidates:
            _, _, input_candidate = max(input_candidates, key=lambda item: (item[0], item[1]))
        else:
            input_candidate = lines[-1] if lines else text.strip()
        input_text = self._clean_alert_display_text(
            self._regex_redact(str(input_candidate or "").strip()),
            kind="input",
        ) or "<empty>"

        output_text = ""
        if output_candidates:
            _, _, output_candidate = max(output_candidates, key=lambda item: (item[0], item[1]))
            output_text = self._clean_alert_display_text(
                self._regex_redact(str(output_candidate or "").strip()),
                kind="output",
            )
        return input_text or "<empty>", output_text.strip()

    def append_history(self, conversation_id: str, role: str, text: str, inspection: GuardInspection) -> None:
        safe_name = re.sub(r"[^a-zA-Z0-9_:-]", "_", conversation_id)
        history_file = self.history_dir / f"{safe_name}.jsonl"
        row = {
            "ts": time.time(),
            "conversation_id": conversation_id,
            "role": role,
            "text": inspection.sanitized_text,
            "suspicious": inspection.suspicious,
            "reasons": inspection.reasons,
            "confidence": inspection.confidence,
        }
        with open(history_file, "a") as f:
            f.write(json.dumps(row) + "\n")

    @staticmethod
    def _severity_details(confidence: float, flags: list[str], source: str) -> tuple[str, int]:
        score = max(0.0, float(confidence or 0.0))
        source_family, _ = ConversationGuard._alert_source_family(source)
        flag_slugs = {
            ConversationGuard.alert_flag_slug(flag)
            for flag in (flags or [])
            if ConversationGuard.alert_flag_slug(flag)
        }
        if "data_exfiltration" in flag_slugs:
            score = max(score, 0.82)
        if "prompt_injection" in flag_slugs:
            score = max(score, 0.72)
        if {"prompt_injection", "data_exfiltration"}.issubset(flag_slugs):
            score = max(score, 0.9)
        if source_family == "runtime_events" and flag_slugs:
            score = min(1.0, score + 0.05)
        if score >= 0.85:
            return "CRITICAL", 4
        if score >= 0.70:
            return "HIGH", 3
        if score >= 0.50:
            return "MEDIUM", 2
        return "LOW", 1

    def _record_alert(
        self,
        *,
        conversation_id: str,
        source: str,
        snippet: str,
        input_preview: str = "",
        output_preview: str = "",
        reasons: list[str],
        confidence: float,
        app_name: Optional[str],
        flags: list[str],
        primary_flag: str,
        severity: str,
        severity_rank: int,
        fingerprint: str,
        status: str,
    ) -> None:
        try:
            source_family, _ = self._alert_source_family(source)
            input_text = str(input_preview or "").strip()
            output_text = str(output_preview or "").strip()
            if not input_text and not output_text:
                input_text, output_text = self._extract_io_from_snippet(snippet)
            armorer_db.insert_security_alert(
                self.config_dir,
                {
                    "ts": time.time(),
                    "agent": str(app_name or conversation_id or "").strip(),
                    "conversation_id": conversation_id,
                    "source": source,
                    "source_family": source_family,
                    "severity": severity,
                    "severity_rank": severity_rank,
                    "confidence": round(float(confidence or 0.0), 2),
                    "flags": list(flags or []),
                    "primary_flag": primary_flag,
                    "reasons": list(reasons or []),
                    "input_preview": input_text,
                    "output_preview": output_text,
                    "incident_marker": self._extract_incident_marker(snippet),
                    "status": status,
                    "dedupe_fingerprint": fingerprint,
                },
            )
        except Exception:
            pass

    def notify_suspicious(
        self,
        channel_id: str,
        conversation_id: str,
        source: str,
        snippet: str,
        reasons: list[str],
        confidence: float,
        app_name: Optional[str] = None,
        input_preview: str = "",
        output_preview: str = "",
    ) -> bool:
        def _pretty_rule(raw: str) -> str:
            text = str(raw or "").strip()
            if not text:
                return "unknown"
            if ":" in text:
                text = text.split(":", 1)[1].strip() or text
            return text

        flags = self.summarize_flags(reasons)
        reason_items = reasons[:8] if reasons else ["unknown"]
        top_reason = _pretty_rule(reason_items[0])
        severity, severity_rank = self._severity_details(confidence, flags, source)
        primary_flag = flags[0] if flags else "Suspicious behavior"
        primary_slug = self.alert_flag_slug(primary_flag)
        suppressed, fingerprint = self._should_suppress_alert(conversation_id, flags, source, snippet)
        self._record_alert(
            conversation_id=conversation_id,
            source=source,
            snippet=snippet,
            input_preview=input_preview,
            output_preview=output_preview,
            reasons=reasons,
            confidence=confidence,
            app_name=app_name,
            flags=flags,
            primary_flag=primary_flag,
            severity=severity,
            severity_rank=severity_rank,
            fingerprint=fingerprint,
            status="suppressed" if suppressed else "open",
        )
        if suppressed:
            return False
        if not self.notifier or not channel_id:
            return False
        _, source_label = self._alert_source_family(source)
        incident_marker = self._extract_incident_marker(snippet)
        input_text, output_text = self._extract_io_from_snippet(snippet)
        lines = [
            "⚠️ ARMORER GUARD ALERT",
            "",
            f"Agent: {app_name or conversation_id}",
            f"Severity: {severity} ({confidence:.2f})",
            f"Detected: {', '.join(flags) if flags else 'Suspicious behavior'}",
            f"Source: {source_label}",
            f"Signal: {top_reason}",
        ]
        if incident_marker:
            lines.append(f"Session: {incident_marker}")
        lines.extend(
            [
                "",
                "Input:",
                input_text,
            ]
        )
        if output_text:
            lines.extend(
                [
                    "",
                    "Output:",
                    output_text,
                ]
            )
        lines.extend(
            [
                "",
                "Actions:",
                f"• /guard steer {conversation_id} <instruction>",
                f"• /guard shutdown {conversation_id}",
            ]
        )
        msg = "\n".join(lines)
        reply_markup = None
        if app_name:
            reply_markup = {
                "inline_keyboard": [
                    [
                        {"text": "Auto Steer", "callback_data": f"guard|agent-autosteer|{app_name}"},
                        {"text": "Shutdown", "callback_data": f"guard|agent-shutdown|{app_name}"},
                    ],
                    [
                        {"text": "Scan", "callback_data": f"guard|agent-scan|{app_name}"},
                        {"text": "Restart", "callback_data": f"guard|agent-restart|{app_name}"},
                    ],
                    [
                        {"text": "Ignore", "callback_data": f"guard|agent-ignore|{app_name}"},
                    ],
                    [
                        {"text": "See less of these", "callback_data": f"guard|alert-less|{primary_slug}"},
                        {"text": "Disable this alert type", "callback_data": f"guard|alert-disable|{primary_slug}"},
                    ],
                ]
            }
        self.notifier(channel_id, msg, reply_markup)
        return True

    def latest_history_snippet(self, conversation_id: str, role: Optional[str] = None) -> str:
        safe_name = re.sub(r"[^a-zA-Z0-9_:-]", "_", conversation_id)
        history_file = self.history_dir / f"{safe_name}.jsonl"
        if not history_file.exists():
            return ""
        lines = history_file.read_text().splitlines()[-100:]
        for line in reversed(lines):
            try:
                row = json.loads(line)
            except Exception:
                continue
            if role and str(row.get("role")) != role:
                continue
            txt = str(row.get("text") or "").strip()
            if txt:
                return txt[:400]
        return ""

    def _load_controls(self) -> dict[str, Any]:
        if not self.controls_file.exists():
            return {}
        try:
            with open(self.controls_file, "r") as f:
                data = json.load(f)
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}

    def _save_controls(self, data: dict[str, Any]) -> None:
        with open(self.controls_file, "w") as f:
            json.dump(data, f, indent=2)

    def set_control(self, conversation_id: str, action: str, value: Optional[str] = None) -> str:
        controls = self._load_controls()
        state = controls.get(conversation_id, {})
        if action == "shutdown":
            state["shutdown"] = True
        elif action == "resume":
            state["shutdown"] = False
            state.pop("steer", None)
        elif action == "steer":
            state["steer"] = str(value or "").strip()
        else:
            return f"Unknown guard action: {action}"

        controls[conversation_id] = state
        self._save_controls(controls)
        return f"Applied `{action}` to `{conversation_id}`."

    def apply_controls(self, conversation_id: str, prompt: str) -> tuple[bool, str, Optional[str]]:
        controls = self._load_controls()
        state = controls.get(conversation_id, {})
        if state.get("shutdown"):
            return True, "", "Conversation is in shutdown mode by guard policy."

        steer = str(state.get("steer", "")).strip()
        if steer:
            merged = f"[Guard Steering Instruction]\n{steer}\n\n[User Message]\n{prompt}"
            state.pop("steer", None)
            controls[conversation_id] = state
            self._save_controls(controls)
            return False, merged, "Applied one-time guard steering instruction."

        return False, prompt, None
