from __future__ import annotations

import json
import logging
import hmac
import hashlib
import os
import re
import sys
import time
import uuid
from collections import deque
from pathlib import Path
from threading import Event, Thread
from typing import Any, Callable, Mapping, Optional

from armorer.config import ConfigManager, resolve_armorer_config_dir
from armorer.messaging.signal import SignalGateway
from armorer.messaging.telegram import TelegramGateway

logger = logging.getLogger(__name__)


def _is_sensitive_text_ui_action(action: Any) -> bool:
    if not isinstance(action, dict):
        return False
    if str(action.get("type") or "").strip().lower() != "text":
        return False
    if bool(action.get("sensitive")):
        return True
    haystacks = [
        str(action.get("message", "") or ""),
        str(action.get("label", "") or ""),
        str(action.get("placeholder", "") or ""),
    ]
    sensitive_terms = ("token", "secret", "password", "api key", "api_key")
    if any(term in text.lower() for text in haystacks for term in sensitive_terms):
        return True
    default = str(action.get("default", "") or "").strip()
    if not default:
        return False
    low_default = default.lower()
    if low_default.startswith(("sk-", "xoxb-", "ghp_", "gho_", "ghu_", "ghs_", "pat_", "api_", "tok_")):
        return True
    if re.fullmatch(r"(?i)AIza[0-9A-Za-z\-_]{20,}", default):
        return True
    if ":" in default:
        left, right = default.split(":", 1)
        if left.isdigit() and len(right.strip()) >= 8:
            return True
    compact = default.strip()
    if " " not in compact and len(compact) >= 24 and all(ch.isalnum() or ch in "._:-" for ch in compact):
        return True
    return False


def _ui_action_hint(ui_action: Any, *, requires_confirmation: bool = False) -> str:
    if not isinstance(ui_action, dict):
        return "Reply `yes` or `no`. Reply `cancel` to abort." if requires_confirmation else ""

    action_type = str(ui_action.get("type") or "").strip().lower()
    if action_type == "select":
        options = [str(opt).strip() for opt in ui_action.get("options", []) if str(opt).strip()]
        if options:
            return "Options: " + ", ".join(options) + ". Reply `cancel` to abort."
        return "Reply with one of the available options or `cancel` to abort."
    if action_type == "confirm":
        return "Reply `yes` or `no`. Reply `cancel` to abort."
    if action_type == "text":
        default = str(ui_action.get("default") or "").strip()
        if default and _is_sensitive_text_ui_action(ui_action):
            return "Reply with the requested value, or `skip` to keep the current value. Reply `cancel` to abort."
        if default and not _is_sensitive_text_ui_action(ui_action):
            return f"Reply with the requested value. Default: {default}. Reply `cancel` to abort."
        return "Reply with the requested value or `cancel` to abort."
    if action_type == "interactive_command":
        command = " ".join(_sanitize_bridge_user_text(str(ui_action.get("command") or "")).split()).strip()
        if command:
            return f"Run locally if needed: {command[:240]}. Reply `cancel` to abort."
    return "Reply `yes` or `no`. Reply `cancel` to abort." if requires_confirmation else ""


def _extract_core_ui_action(raw_response: Any) -> dict[str, Any] | None:
    if isinstance(raw_response, dict):
        ui_action = raw_response.get("ui_action")
        if isinstance(ui_action, dict) and str(ui_action.get("type") or "").strip():
            return dict(ui_action)
        if raw_response.get("requires_confirmation"):
            return {"type": "confirm", "message": str(raw_response.get("message") or "").strip()}
        return None

    text = str(raw_response or "").strip()
    if not text:
        return None

    try:
        parsed = json.loads(text)
    except Exception:
        return None

    if isinstance(parsed, dict):
        return _extract_core_ui_action(parsed)
    return None


def _extract_core_message(raw_response: Any, *, include_ui_hints: bool = False) -> str:
    fallback = "I couldn't generate a response. Please try again."
    sanitize = _sanitize_bridge_user_text

    if isinstance(raw_response, dict):
        msg = str(raw_response.get("message", "")).strip()
        hint = ""
        if include_ui_hints:
            hint = _ui_action_hint(
                raw_response.get("ui_action"),
                requires_confirmation=bool(raw_response.get("requires_confirmation")),
            )
            if msg and hint:
                return sanitize(f"{msg}\n{hint}")
        if msg:
            return sanitize(msg)
        err = str(raw_response.get("error", "")).strip()
        if err and hint:
            return sanitize(f"{err}\n{hint}")
        if err:
            return sanitize(err)
        if hint:
            return sanitize(hint)
        return sanitize(fallback)

    text = str(raw_response or "").strip()
    if not text:
        return sanitize(fallback)

    try:
        parsed = json.loads(text)
    except Exception:
        return sanitize(text)

    if isinstance(parsed, dict):
        msg = str(parsed.get("message", "")).strip()
        hint = ""
        if include_ui_hints:
            hint = _ui_action_hint(
                parsed.get("ui_action"),
                requires_confirmation=bool(parsed.get("requires_confirmation")),
            )
            if msg and hint:
                return sanitize(f"{msg}\n{hint}")
        if msg:
            return sanitize(msg)
        err = str(parsed.get("error", "")).strip()
        if err and hint:
            return sanitize(f"{err}\n{hint}")
        if err:
            return sanitize(err)
        if hint:
            return sanitize(hint)
        return sanitize(fallback)
    return sanitize(text)


def _telegram_ui_reply_markup(raw_response: Any) -> Optional[dict[str, Any]]:
    ui_action = _extract_core_ui_action(raw_response)
    if not isinstance(ui_action, dict):
        return None

    action_type = str(ui_action.get("type") or "").strip().lower()
    if action_type == "confirm":
        return {
            "inline_keyboard": [
                [
                    {"text": "Yes", "callback_data": "ui|confirm|yes"},
                    {"text": "No", "callback_data": "ui|confirm|no"},
                ],
                [
                    {"text": "Cancel", "callback_data": "ui|cancel|cancel"},
                ],
            ]
        }

    if action_type != "select":
        return None

    options = [str(opt).strip() for opt in ui_action.get("options", []) if str(opt).strip()]
    if not options:
        return None

    keyboard: list[list[dict[str, str]]] = []
    row: list[dict[str, str]] = []
    for option in options[:8]:
        display_text = _sanitize_bridge_user_text(option).strip() or "option"
        # If sanitization changed the visible text, this option likely contains
        # sensitive material; avoid echoing the raw value in callback_data.
        if display_text != option:
            return None
        callback_data = f"ui|select|{option}"
        if len(callback_data.encode("utf-8")) > 64:
            return None
        row.append({"text": display_text[:32], "callback_data": callback_data})
        if len(row) == 2:
            keyboard.append(row)
            row = []
    if row:
        keyboard.append(row)
    keyboard.append([{"text": "Cancel", "callback_data": "ui|cancel|cancel"}])
    return {"inline_keyboard": keyboard} if keyboard else None


def format_queue_cleanup_note(cleaned: list[str] | tuple[str, ...] | None) -> str:
    entries = [str(item).strip() for item in (cleaned or []) if str(item).strip()]
    if not entries:
        return ""
    preview = entries[:8]
    suffix = " ..." if len(entries) > len(preview) else ""
    return "Cleaned stale queue files before retrying: " + ", ".join(preview) + suffix + "."


def _sanitize_bridge_user_text(text: str) -> str:
    redacted = str(text or "")
    redacted = re.sub(r"\b\d{5,}:[A-Za-z0-9_-]{20,}\b", "[REDACTED_TELEGRAM_TOKEN]", redacted)
    redacted = re.sub(r"(?i)\bsk-[A-Za-z0-9_-]{20,}\b", "[REDACTED_OPENAI_KEY]", redacted)
    redacted = re.sub(r"(?i)\bAIza[0-9A-Za-z\-_]{20,}\b", "[REDACTED_GEMINI_KEY]", redacted)
    return redacted


def format_progress_note(message: str) -> str:
    line = " ".join(_sanitize_bridge_user_text(str(message or "")).split()).strip()
    if not line:
        return ""
    return f"Progress: {line[:240]}"


def build_local_cli_task_metadata() -> dict[str, str]:
    channel_id = f"pid:{os.getpid()}"
    try:
        if sys.stdin.isatty():
            tty_name = str(os.ttyname(sys.stdin.fileno()) or "").strip()
            tty_label = Path(tty_name).name.strip() if tty_name else ""
            if tty_label:
                channel_id = f"tty:{tty_label}"
    except Exception:
        pass
    return {"channel_id": channel_id}


class InboxOutboxRelay:
    """Bridge utility for posting prompts to core and reading responses."""

    def __init__(self, config_dir: Path, signing_key: Optional[str] = None):
        self.config_dir = config_dir
        self.inbox = config_dir / "inbox"
        self.outbox = config_dir / "outbox"
        self.status_dir = config_dir / "status"
        self.signing_key = str(signing_key or "").strip()
        self.last_submit_cleanup: list[str] = []
        self.inbox.mkdir(parents=True, exist_ok=True)
        self.outbox.mkdir(parents=True, exist_ok=True)
        self.status_dir.mkdir(parents=True, exist_ok=True)

    def _task_signature(self, task: Mapping[str, Any]) -> Optional[str]:
        if not self.signing_key:
            return None
        payload = {
            "id": task.get("id"),
            "prompt": task.get("prompt"),
            "source": task.get("source"),
            "timestamp": task.get("timestamp"),
            "metadata": task.get("metadata", {}),
        }
        body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
        return hmac.new(self.signing_key.encode("utf-8"), body, hashlib.sha256).hexdigest()

    @staticmethod
    def _task_scope(source: str, metadata: Optional[Mapping[str, Any]] = None) -> str:
        meta = metadata or {}
        source_name = str(source or "cli").strip().lower() 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 cleanup_stale_artifacts(self, max_age_seconds: int = 3600) -> list[str]:
        cleaned: list[str] = []
        cutoff = time.time() - max(60, int(max_age_seconds))
        stale_task_paths: dict[str, list[tuple[str, Path]]] = {}

        for label, base in (
            ("inbox", self.inbox),
            ("outbox", self.outbox),
            ("status", self.status_dir),
        ):
            for path in base.glob("*.json"):
                try:
                    if path.stat().st_mtime > cutoff:
                        continue
                    stale_task_paths.setdefault(path.stem, []).append((label, path))
                except Exception:
                    continue

        for _task_id, entries in stale_task_paths.items():
            labels = {label for label, _path in entries}
            # Only remove fully completed stale bundles that already have an outbox
            # response. A long-running task may legitimately have only inbox+status
            # peers for a while, and submit_task() should not purge active work.
            if "outbox" not in labels:
                continue
            for label, path in entries:
                try:
                    if path.exists():
                        path.unlink()
                        cleaned.append(f"{label}:{path.name}")
                except Exception:
                    continue

        for path in self.inbox.glob("*.json.tmp"):
            try:
                if path.stat().st_mtime > cutoff:
                    continue
                path.unlink()
                cleaned.append(f"inbox_tmp:{path.name}")
            except Exception:
                continue
        for path in self.inbox.glob("*.json"):
            try:
                if path.stat().st_mtime > cutoff:
                    continue
                outbox_peer = self.outbox / path.name
                status_peer = self.status_dir / path.name
                if outbox_peer.exists() or status_peer.exists():
                    continue
                # Leave stale inbox-only tasks in place for explicit supersession
                # or manual inspection instead of silently dropping queued work.
            except Exception:
                continue
        for base, label in ((self.outbox, "outbox"), (self.status_dir, "status")):
            for path in base.glob("*.json"):
                try:
                    if path.stat().st_mtime > cutoff:
                        continue
                    inbox_peer = self.inbox / path.name
                    if inbox_peer.exists():
                        continue
                    path.unlink()
                    cleaned.append(f"{label}:{path.name}")
                except Exception:
                    continue
        return cleaned

    def cleanup_superseded_tasks(self, source: str, metadata: Optional[Mapping[str, Any]] = None) -> list[str]:
        cleaned: list[str] = []
        scope = self._task_scope(source, metadata)
        for inbox_path in self.inbox.glob("*.json"):
            try:
                task = json.loads(inbox_path.read_text())
            except Exception:
                continue
            if not isinstance(task, dict):
                continue
            task_scope = self._task_scope(str(task.get("source") or "cli"), task.get("metadata") if isinstance(task.get("metadata"), dict) else None)
            if task_scope != scope:
                continue
            for label, base in (
                ("inbox", self.inbox),
                ("outbox", self.outbox),
                ("status", self.status_dir),
            ):
                path = base / inbox_path.name
                try:
                    if path.exists():
                        path.unlink()
                        cleaned.append(f"{label}:{path.name}")
                except Exception:
                    continue
        return cleaned

    def submit_task(self, prompt: str, source: str, metadata: Optional[Mapping[str, Any]] = None) -> str:
        cleaned = self.cleanup_stale_artifacts()
        cleaned.extend(self.cleanup_superseded_tasks(source=source, metadata=metadata))
        self.last_submit_cleanup = cleaned
        task_id = str(uuid.uuid4())
        task: dict[str, Any] = {
            "id": task_id,
            "prompt": prompt,
            "source": source,
            "timestamp": time.time(),
        }
        if metadata:
            task["metadata"] = dict(metadata)
        sig = self._task_signature(task)
        if sig:
            task["signature"] = sig
        final_path = self.inbox / f"{task_id}.json"
        temp_path = self.inbox / f".{task_id}.json.tmp"
        temp_path.write_text(json.dumps(task))
        temp_path.replace(final_path)
        return task_id

    def cleanup_task_files(self, task_id: str) -> list[str]:
        cleaned: list[str] = []
        for label, base in (
            ("inbox", self.inbox),
            ("outbox", self.outbox),
            ("status", self.status_dir),
        ):
            path = base / f"{task_id}.json"
            try:
                if path.exists():
                    path.unlink()
                    cleaned.append(label)
            except Exception:
                continue
        return cleaned

    def wait_for_response(
        self,
        task_id: str,
        timeout: int = 300,
        poll_interval: float = 0.5,
        status_callback: Optional[Callable[[str], None]] = None,
        include_ui_hints: bool = False,
    ) -> dict[str, Any]:
        result_path = self.outbox / f"{task_id}.json"
        status_path = self.status_dir / f"{task_id}.json"

        wait_timeout = max(0.0, float(timeout))
        wait_poll_interval = max(0.05, float(poll_interval))
        waited = 0.0
        last_status = ""
        last_parse_error = ""
        last_status_parse_error = ""
        while True:
            if status_path.exists():
                try:
                    status_raw = json.loads(status_path.read_text())
                    msg = str(status_raw.get("message", "")).strip()
                    if msg and msg != last_status:
                        last_status = msg
                        last_status_parse_error = ""
                        if callable(status_callback):
                            try:
                                status_callback(msg)
                            except Exception as exc:
                                logger.debug("Status callback failed for task %s: %s", task_id, exc)
                except Exception as exc:
                    last_status_parse_error = str(exc).strip() or exc.__class__.__name__
                    logger.debug(
                        "Status parse failed for task %s; waiting for complete JSON: %s",
                        task_id,
                        last_status_parse_error,
                    )

            if result_path.exists():
                try:
                    raw = json.loads(result_path.read_text())
                except Exception as exc:
                    # Core may still be writing outbox JSON; keep polling until timeout.
                    last_parse_error = str(exc).strip() or exc.__class__.__name__
                    logger.debug("Outbox parse failed for task %s; waiting for complete JSON: %s", task_id, last_parse_error)
                else:
                    message = _extract_core_message(raw.get("response", ""), include_ui_hints=include_ui_hints)
                    cleaned = self.cleanup_task_files(task_id)
                    return {
                        "id": task_id,
                        "message": message,
                        "raw": raw,
                        "last_status": last_status,
                        "cleaned_up": cleaned,
                    }

            if waited >= wait_timeout:
                break
            time.sleep(wait_poll_interval)
            waited += wait_poll_interval

        cleaned = self.cleanup_task_files(task_id)
        safe_last_status = _sanitize_bridge_user_text(last_status)
        detail = f" Last progress: {safe_last_status}." if safe_last_status else ""
        status_parse_detail = (
            f" Last status update was unreadable JSON: {_sanitize_bridge_user_text(last_status_parse_error)[:180]}."
            if (not last_status and last_status_parse_error)
            else ""
        )
        parse_detail = (
            f" Last core response was invalid JSON: {_sanitize_bridge_user_text(last_parse_error)[:180]}."
            if last_parse_error
            else ""
        )
        cleanup_note = f" Cleaned queue files: {', '.join(cleaned)}." if cleaned else ""
        return {
            "id": task_id,
            "message": (
                f"Timed out waiting for Armorer Core response.{detail}{status_parse_detail}{parse_detail}{cleanup_note} "
                "Retry the request if needed."
            ),
            "raw": None,
            "timed_out": True,
            "last_status": last_status,
            "cleaned_up": cleaned,
        }


class TelegramCoreBridge:
    """Long-polling Telegram bridge that forwards messages to Armorer Core."""

    def __init__(
        self,
        gateway: TelegramGateway,
        relay: InboxOutboxRelay,
        response_timeout: int = 300,
        state_file: Optional[Path] = None,
        typing_interval: float = 4.0,
    ):
        self.gateway = gateway
        self.relay = relay
        self.response_timeout = response_timeout
        self.config = ConfigManager()
        base_dir = relay.config_dir if isinstance(getattr(relay, "config_dir", None), Path) else resolve_armorer_config_dir()
        self.state_file = state_file or (base_dir / "telegram_gateway_state.json")
        self._recent_targets_file = base_dir / "telegram_recent_targets.json"
        self.typing_interval = max(1.0, float(typing_interval))
        self.next_offset = 0
        self._recent_update_ids: deque[int] = deque(maxlen=512)
        self._pending_confirmations_file = base_dir / "telegram_pending_confirmations.json"
        self._audit_file = base_dir / "security_audit.jsonl"
        self._rate_limit_events: dict[str, deque[float]] = {}
        self._pending_confirmations: dict[str, dict[str, Any]] = {}
        self._admin_user_ids: set[str] = set()
        self._admin_chat_ids: set[str] = set()
        self._allow_unconfigured_admins = False
        self._rate_limit_count = 15
        self._rate_limit_window_s = 60
        self._confirm_ttl_s = 60
        self._load_state()
        self._load_security_policy()
        self._load_confirmations()

    @staticmethod
    def _csv_set(raw: Optional[str]) -> set[str]:
        if not raw:
            return set()
        return {x.strip() for x in str(raw).split(",") if x.strip()}

    def _audit(self, event: str, **details: Any) -> None:
        row = {"ts": time.time(), "provider": "telegram", "event": event, "details": details}
        try:
            self._audit_file.parent.mkdir(parents=True, exist_ok=True)
            with open(self._audit_file, "a") as f:
                f.write(json.dumps(row, sort_keys=True) + "\n")
        except Exception as exc:
            logger.warning("Failed writing security audit event: %s", exc)

    def _load_security_policy(self) -> None:
        admin_users = (
            self.config.get_key("ARMORER_TELEGRAM_ADMIN_USER_IDS")
            or self.config.get_key("ARMORER_ADMIN_USER_IDS")
            or ""
        )
        admin_chats = (
            self.config.get_key("ARMORER_TELEGRAM_ADMIN_CHAT_IDS")
            or self.config.get_key("ARMORER_ADMIN_CHAT_IDS")
            or ""
        )
        self._admin_user_ids = self._csv_set(admin_users)
        self._admin_chat_ids = self._csv_set(admin_chats)
        open_mode = (
            self.config.get_key("ARMORER_TELEGRAM_ALLOW_UNCONFIGURED_ADMINS")
            or self.config.get_key("ARMORER_GATEWAY_ALLOW_UNCONFIGURED_ADMINS")
            or "false"
        )
        self._allow_unconfigured_admins = str(open_mode).strip().lower() in {"1", "true", "on", "yes"}

        try:
            self._rate_limit_count = max(1, int(self.config.get_key("ARMORER_TELEGRAM_RATE_LIMIT_COUNT") or "15"))
        except Exception:
            self._rate_limit_count = 15
        try:
            self._rate_limit_window_s = max(10, int(self.config.get_key("ARMORER_TELEGRAM_RATE_LIMIT_WINDOW_S") or "60"))
        except Exception:
            self._rate_limit_window_s = 60
        try:
            self._confirm_ttl_s = max(15, int(self.config.get_key("ARMORER_CONFIRM_TTL_S") or "60"))
        except Exception:
            self._confirm_ttl_s = 60

    def _load_confirmations(self) -> None:
        try:
            if self._pending_confirmations_file.exists():
                raw = json.loads(self._pending_confirmations_file.read_text())
                if isinstance(raw, dict):
                    self._pending_confirmations = raw
        except Exception as exc:
            logger.warning("Failed loading pending confirmations: %s", exc)
            self._pending_confirmations = {}
        self._expire_confirmations()

    def _save_confirmations(self) -> None:
        try:
            self._pending_confirmations_file.parent.mkdir(parents=True, exist_ok=True)
            self._pending_confirmations_file.write_text(json.dumps(self._pending_confirmations, sort_keys=True))
        except Exception as exc:
            logger.warning("Failed saving pending confirmations: %s", exc)

    def _expire_confirmations(self) -> None:
        now = time.time()
        expired = [tok for tok, row in self._pending_confirmations.items() if float(row.get("expires_at", 0)) < now]
        for tok in expired:
            self._pending_confirmations.pop(tok, None)
        if expired:
            self._save_confirmations()

    def _latest_confirmation(
        self,
        *,
        channel_id: str,
        user_id: str,
    ) -> list[tuple[str, dict[str, Any]]]:
        self._expire_confirmations()
        matches: list[tuple[str, dict[str, Any]]] = []
        for token, row in self._pending_confirmations.items():
            if str(row.get("channel_id")) != str(channel_id):
                continue
            if str(row.get("user_id")) != str(user_id):
                continue
            matches.append((token, row))
        matches.sort(key=lambda item: float(item[1].get("created_at", 0)))
        return matches

    def _is_admin(self, user_id: str, channel_id: str) -> bool:
        # Secure-by-default: require explicit allowlist unless explicitly opened.
        if not self._admin_user_ids and not self._admin_chat_ids:
            return self._allow_unconfigured_admins
        return (user_id in self._admin_user_ids) or (channel_id in self._admin_chat_ids)

    def _check_rate_limit(self, user_id: str) -> bool:
        now = time.time()
        bucket = self._rate_limit_events.setdefault(user_id, deque())
        while bucket and (now - bucket[0]) > self._rate_limit_window_s:
            bucket.popleft()
        if len(bucket) >= self._rate_limit_count:
            return False
        bucket.append(now)
        return True

    @staticmethod
    def _requires_confirmation(prompt: str) -> bool:
        t = str(prompt or "").strip().lower()
        if not t:
            return False
        if t.startswith("/guard agent-shutdown") or t.startswith("/guard agent-restart"):
            return True
        patterns = [
            r"\bshutdown\b",
            r"\brestart\b",
            r"\binstall\b",
            r"\buninstall\b",
            r"\brm\s+-rf\b",
            r"\bdocker\s+compose\s+down\b",
            r"\bshell\b",
            r"\brun\s+command\b",
            r"\bexecute\s+command\b",
        ]
        return any(re.search(p, t) for p in patterns)

    def _issue_confirmation(self, channel_id: str, user_id: str, prompt: str, reply_to: Optional[str]) -> dict[str, Any]:
        self._expire_confirmations()
        replaced = [token for token, _ in self._latest_confirmation(channel_id=channel_id, user_id=user_id)]
        for token in replaced:
            self._pending_confirmations.pop(token, None)
        token = uuid.uuid4().hex[:8]
        self._pending_confirmations[token] = {
            "channel_id": channel_id,
            "user_id": user_id,
            "prompt": prompt,
            "created_at": time.time(),
            "expires_at": time.time() + self._confirm_ttl_s,
        }
        self._save_confirmations()
        if replaced:
            self._audit(
                "confirmation_replaced",
                channel_id=channel_id,
                user_id=user_id,
                replaced=len(replaced),
            )
        self._audit("confirmation_issued", channel_id=channel_id, user_id=user_id, token=token)
        msg = (
            "High-risk action detected.\n"
            f"Reply `confirm` or `cancel` within {self._confirm_ttl_s}s to act on this request.\n"
            f"Explicit approval also works: `/confirm {token}`."
        )
        markup = {
            "inline_keyboard": [
                [
                    {"text": "Confirm", "callback_data": f"confirm|{token}|ok"},
                    {"text": "Cancel", "callback_data": f"confirm|{token}|cancel"},
                ]
            ]
        }
        return self.gateway.send_message(channel_id, msg, reply_to=reply_to, reply_markup=markup)

    def _resolve_confirmation_token(
        self,
        *,
        token: str,
        channel_id: str,
        user_id: str,
        reply_to: Optional[str],
    ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
        row = self._pending_confirmations.get(token)
        if not row:
            return None, self.gateway.send_message(channel_id, "Invalid or expired confirmation token.", reply_to=reply_to)
        if str(row.get("channel_id")) != str(channel_id) or str(row.get("user_id")) != str(user_id):
            self._audit("confirmation_denied_mismatch", channel_id=channel_id, user_id=user_id, token=token)
            return None, self.gateway.send_message(channel_id, "Token does not match this user/chat.", reply_to=reply_to)
        if float(row.get("expires_at", 0)) < time.time():
            self._pending_confirmations.pop(token, None)
            self._save_confirmations()
            return None, self.gateway.send_message(channel_id, "Confirmation token expired.", reply_to=reply_to)
        return row, None

    def _handle_confirm_command(self, channel_id: str, user_id: str, text: str, reply_to: Optional[str]) -> Optional[dict[str, Any]]:
        t = str(text or "").strip()
        lower = t.lower()
        token = ""
        row: dict[str, Any] | None = None
        error_response: dict[str, Any] | None = None
        if lower.startswith("/confirm "):
            parts = t.split(None, 1)
            token = parts[1].strip() if len(parts) > 1 else ""
            row, error_response = self._resolve_confirmation_token(
                token=token,
                channel_id=channel_id,
                user_id=user_id,
                reply_to=reply_to,
            )
            if error_response is not None:
                return error_response
        elif lower in {"confirm", "cancel"}:
            matches = self._latest_confirmation(channel_id=channel_id, user_id=user_id)
            if not matches:
                return None
            if len(matches) > 1:
                return self.gateway.send_message(
                    channel_id,
                    "Multiple pending confirmations found. Use `/confirm <token>` from the latest prompt.",
                    reply_to=reply_to,
                )
            token, _row = matches[-1]
            row, error_response = self._resolve_confirmation_token(
                token=token,
                channel_id=channel_id,
                user_id=user_id,
                reply_to=reply_to,
            )
            if error_response is not None:
                return error_response
            if lower == "cancel":
                self._pending_confirmations.pop(token, None)
                self._save_confirmations()
                self._audit("confirmation_cancelled", channel_id=channel_id, user_id=user_id, token=token)
                return self.gateway.send_message(channel_id, "Action cancelled.", reply_to=reply_to)
        else:
            return None

        if error_response is not None:
            return error_response
        if row is None:
            return None
        prompt = str(row.get("prompt", "")).strip()
        self._pending_confirmations.pop(token, None)
        self._save_confirmations()
        self._audit("confirmation_approved", channel_id=channel_id, user_id=user_id, token=token)
        return self._submit_to_core(channel_id=channel_id, user_id=user_id, prompt=prompt, reply_to=reply_to)

    def _submit_to_core(self, channel_id: str, user_id: str, prompt: str, reply_to: Optional[str]) -> dict[str, Any]:
        metadata = {
            "provider": "telegram",
            "channel_id": channel_id,
            "user_id": user_id,
            "message_id": reply_to,
        }
        try:
            task_id = self.relay.submit_task(prompt=prompt, source="telegram", metadata=metadata)
        except Exception as exc:
            self._audit("task_submit_failed", channel_id=channel_id, user_id=user_id, error=str(exc))
            safe_detail = _sanitize_bridge_user_text(str(exc).strip() or exc.__class__.__name__)[:300]
            return self.gateway.send_message(
                channel_id,
                f"Failed to contact Armorer Core: {safe_detail}",
                reply_to=reply_to,
            )
        self._audit("task_submitted", channel_id=channel_id, user_id=user_id, task_id=task_id)
        cleanup_note = format_queue_cleanup_note(getattr(self.relay, "last_submit_cleanup", []))
        if cleanup_note:
            try:
                self.gateway.send_message(channel_id, cleanup_note, reply_to=reply_to)
            except Exception as exc:
                logger.debug("Failed to send Telegram cleanup note: %s", exc)
        stop_typing = Event()
        typing_thread = Thread(target=self._typing_loop, args=(channel_id, stop_typing), daemon=True)
        typing_thread.start()

        def _status_update(msg: str) -> None:
            note = format_progress_note(msg)
            if not note:
                return
            try:
                self.gateway.send_message(channel_id, note, reply_to=reply_to)
            except Exception as exc:
                logger.debug("Failed to send Telegram progress update: %s", exc)

        try:
            result = self.relay.wait_for_response(
                task_id,
                timeout=self.response_timeout,
                status_callback=_status_update,
                include_ui_hints=True,
            )
            reply_text = result.get("message") or "No response generated."
            reply_markup = _telegram_ui_reply_markup(
                result.get("raw", {}).get("response", "") if isinstance(result.get("raw"), dict) else None
            )
        except Exception as exc:
            self._audit("task_wait_failed", channel_id=channel_id, user_id=user_id, task_id=task_id, error=str(exc))
            safe_detail = _sanitize_bridge_user_text(str(exc).strip() or exc.__class__.__name__)[:300]
            reply_text = f"Failed to contact Armorer Core: {safe_detail}"
            reply_markup = None
        finally:
            stop_typing.set()
            typing_thread.join(timeout=1.0)
        send_kwargs: dict[str, Any] = {"reply_to": reply_to}
        if reply_markup:
            send_kwargs["reply_markup"] = reply_markup
        return self.gateway.send_message(channel_id, reply_text, **send_kwargs)

    def _load_state(self) -> None:
        try:
            if not self.state_file.exists():
                return
            data = json.loads(self.state_file.read_text())
            if isinstance(data, dict):
                offset = data.get("next_offset")
                recent = data.get("recent_update_ids", [])
                if isinstance(offset, int) and offset >= 0:
                    self.next_offset = offset
                if isinstance(recent, list):
                    for item in recent[-512:]:
                        if isinstance(item, int):
                            self._recent_update_ids.append(item)
        except Exception as exc:
            logger.warning("Failed loading Telegram gateway state: %s", exc)

    def _save_state(self) -> None:
        try:
            self.state_file.parent.mkdir(parents=True, exist_ok=True)
            payload = {
                "next_offset": self.next_offset,
                "recent_update_ids": list(self._recent_update_ids),
                "updated_at": time.time(),
            }
            self.state_file.write_text(json.dumps(payload))
        except Exception as exc:
            logger.warning("Failed saving Telegram gateway state: %s", exc)

    def _record_recent_target(self, update: Mapping[str, Any], event: Any) -> None:
        try:
            message = (
                update.get("message")
                or update.get("edited_message")
                or update.get("channel_post")
                or update.get("edited_channel_post")
            )
            if not isinstance(message, Mapping):
                return
            chat = message.get("chat") if isinstance(message.get("chat"), Mapping) else {}
            chat_id = str(event.channel_id or "").strip()
            if not chat_id:
                return
            row = {
                "chat_id": chat_id,
                "chat_type": str(chat.get("type") or "").strip(),
                "title": str(chat.get("title") or chat.get("username") or chat_id).strip(),
                "user_id": str(event.user_id or "").strip(),
                "updated_at": time.time(),
            }
            existing: list[dict[str, Any]] = []
            if self._recent_targets_file.exists():
                try:
                    raw = json.loads(self._recent_targets_file.read_text(encoding="utf-8"))
                    if isinstance(raw, list):
                        existing = [item for item in raw if isinstance(item, dict)]
                except Exception:
                    existing = []
            next_rows = [row]
            for item in existing:
                existing_chat_id = str(item.get("chat_id") or "").strip()
                if not existing_chat_id or existing_chat_id == chat_id:
                    continue
                next_rows.append(item)
            self._recent_targets_file.parent.mkdir(parents=True, exist_ok=True)
            self._recent_targets_file.write_text(json.dumps(next_rows[:100], ensure_ascii=False, indent=2), encoding="utf-8")
        except Exception as exc:
            logger.debug("Failed recording recent Telegram target: %s", exc)

    def process_update(self, update: Mapping[str, Any]) -> Optional[dict[str, Any]]:
        callback = update.get("callback_query") if isinstance(update, Mapping) else None
        if isinstance(callback, Mapping):
            return self._process_callback_query(update, callback)

        event = self.gateway.normalize_event(update)
        if event is None:
            return None
        self._record_recent_target(update, event)
        if not event.text.strip():
            return None
        if not self._is_admin(event.user_id, event.channel_id):
            self._audit("authz_denied", channel_id=event.channel_id, user_id=event.user_id)
            return self.gateway.send_message(
                event.channel_id,
                (
                    "Access denied. Configure ARMORER_TELEGRAM_ADMIN_USER_IDS/CHAT_IDS "
                    "or explicitly enable ARMORER_TELEGRAM_ALLOW_UNCONFIGURED_ADMINS=true."
                ),
                reply_to=event.message_id,
            )
        if not self._check_rate_limit(event.user_id):
            self._audit("rate_limited", channel_id=event.channel_id, user_id=event.user_id)
            return self.gateway.send_message(
                event.channel_id,
                f"Rate limit exceeded. Max {self._rate_limit_count} requests/{self._rate_limit_window_s}s.",
                reply_to=event.message_id,
            )

        confirmed = self._handle_confirm_command(
            channel_id=event.channel_id,
            user_id=event.user_id,
            text=event.text,
            reply_to=event.message_id,
        )
        if confirmed is not None:
            return confirmed
        if self._requires_confirmation(event.text):
            return self._issue_confirmation(
                channel_id=event.channel_id,
                user_id=event.user_id,
                prompt=event.text,
                reply_to=event.message_id,
            )
        return self._submit_to_core(
            channel_id=event.channel_id,
            user_id=event.user_id,
            prompt=event.text,
            reply_to=event.message_id,
        )

    def _process_callback_query(
        self, update: Mapping[str, Any], callback: Mapping[str, Any]
    ) -> Optional[dict[str, Any]]:
        data = str(callback.get("data") or "").strip()
        cb_id = str(callback.get("id") or "").strip()
        if cb_id:
            try:
                self.gateway.answer_callback_query(cb_id, text="Action received")
            except Exception:
                pass
        if not data.startswith("guard|"):
            if data.startswith("ui|"):
                parts = data.split("|", 2)
                if len(parts) < 3:
                    return None
                _, action, value = parts
                message = callback.get("message") if isinstance(callback.get("message"), Mapping) else {}
                chat = message.get("chat") if isinstance(message, Mapping) else {}
                sender = callback.get("from") if isinstance(callback.get("from"), Mapping) else {}
                channel_id = str(chat.get("id") or "")
                user_id = str(sender.get("id") or "")
                reply_to = str(message.get("message_id")) if message.get("message_id") is not None else None
                if not self._is_admin(user_id, channel_id):
                    self._audit("authz_denied_callback", channel_id=channel_id, user_id=user_id, action=action)
                    if channel_id:
                        return self.gateway.send_message(channel_id, "Access denied.", reply_to=reply_to)
                    return None
                if action == "select":
                    return self._submit_to_core(
                        channel_id=channel_id,
                        user_id=user_id,
                        prompt=f"UI selection: {value}",
                        reply_to=reply_to,
                    )
                if action == "cancel":
                    return self._submit_to_core(
                        channel_id=channel_id,
                        user_id=user_id,
                        prompt="cancel",
                        reply_to=reply_to,
                    )
                if action == "confirm":
                    normalized = "yes" if str(value or "").strip().lower() in {"y", "yes", "true", "1", "ok"} else "no"
                    return self._submit_to_core(
                        channel_id=channel_id,
                        user_id=user_id,
                        prompt=f"UI confirmation: {normalized}",
                        reply_to=reply_to,
                    )
                return None
            # Confirmation callbacks.
            if data.startswith("confirm|"):
                parts = data.split("|")
                if len(parts) < 3:
                    return None
                _, token, action = parts[:3]
                message = callback.get("message") if isinstance(callback.get("message"), Mapping) else {}
                chat = message.get("chat") if isinstance(message, Mapping) else {}
                sender = callback.get("from") if isinstance(callback.get("from"), Mapping) else {}
                channel_id = str(chat.get("id") or "")
                user_id = str(sender.get("id") or "")
                reply_to = str(message.get("message_id")) if message.get("message_id") is not None else None
                if not self._is_admin(user_id, channel_id):
                    self._audit("authz_denied_callback", channel_id=channel_id, user_id=user_id, action=action)
                    if channel_id:
                        return self.gateway.send_message(channel_id, "Access denied.", reply_to=reply_to)
                    return None
                row, error_response = self._resolve_confirmation_token(
                    token=token,
                    channel_id=channel_id,
                    user_id=user_id,
                    reply_to=reply_to,
                )
                if error_response is not None:
                    return error_response
                if row is None:
                    return None
                if action == "cancel":
                    self._pending_confirmations.pop(token, None)
                    self._save_confirmations()
                    self._audit("confirmation_cancelled", channel_id=channel_id, user_id=user_id, token=token)
                    if channel_id:
                        return self.gateway.send_message(channel_id, "Action cancelled.", reply_to=reply_to)
                    return {"ok": True, "provider": "telegram", "raw": {"cancelled": True}}
                return self._handle_confirm_command(channel_id=channel_id, user_id=user_id, text=f"/confirm {token}", reply_to=reply_to)
            return None

        parts = data.split("|")
        if len(parts) < 3:
            return None
        _, action, app_name = parts[:3]
        prompt_map = {
            "agent-autosteer": f"/guard agent-autosteer {app_name}",
            "agent-shutdown": f"/guard agent-shutdown {app_name}",
            "agent-scan": f"/guard agent-scan {app_name}",
            "agent-restart": f"/guard agent-restart {app_name}",
            "agent-resume": f"/guard agent-resume {app_name}",
        }
        prompt = prompt_map.get(action)
        if not prompt:
            return None

        message = callback.get("message") if isinstance(callback.get("message"), Mapping) else {}
        chat = message.get("chat") if isinstance(message, Mapping) else {}
        sender = callback.get("from") if isinstance(callback.get("from"), Mapping) else {}
        channel_id = str(chat.get("id") or "")
        user_id = str(sender.get("id") or "")
        reply_to = str(message.get("message_id")) if message.get("message_id") is not None else None
        if not self._is_admin(user_id, channel_id):
            self._audit("authz_denied_callback", channel_id=channel_id, user_id=user_id, action=action)
            if channel_id:
                return self.gateway.send_message(channel_id, "Access denied.", reply_to=reply_to)
            return None
        if self._requires_confirmation(prompt):
            if channel_id:
                return self._issue_confirmation(channel_id=channel_id, user_id=user_id, prompt=prompt, reply_to=reply_to)
            return None
        out = self._submit_to_core(channel_id=channel_id, user_id=user_id, prompt=prompt, reply_to=reply_to)
        if channel_id:
            return out
        return {"ok": True, "provider": "telegram", "raw": {"message": "callback processed"}}

    def _typing_loop(self, channel_id: str, stop_event: Event) -> None:
        while not stop_event.is_set():
            try:
                self.gateway.send_chat_action(channel_id, action="typing")
            except Exception as exc:
                logger.debug("Failed to send typing action: %s", exc)
            stop_event.wait(self.typing_interval)

    def _best_effort_failure_reply(self, update: Mapping[str, Any], message: str) -> None:
        safe_message = _sanitize_bridge_user_text(str(message or "")).strip()[:600]
        if not safe_message:
            safe_message = "Armorer hit an internal error while processing this request."
        try:
            callback = update.get("callback_query") if isinstance(update, Mapping) else None
            if isinstance(callback, Mapping):
                cb_message = callback.get("message") if isinstance(callback.get("message"), Mapping) else {}
                chat = cb_message.get("chat") if isinstance(cb_message, Mapping) else {}
                channel_id = str(chat.get("id") or "")
                reply_to = cb_message.get("message_id") if isinstance(cb_message, Mapping) else None
                if channel_id:
                    self.gateway.send_message(
                        channel_id,
                        safe_message,
                        reply_to=str(reply_to) if reply_to is not None else None,
                    )
                return

            event = None
            try:
                event = self.gateway.normalize_event(update)
            except Exception:
                event = None
            if event is not None and str(event.channel_id).strip():
                self.gateway.send_message(event.channel_id, safe_message, reply_to=event.message_id)
                return

            raw_message = (
                update.get("message")
                or update.get("edited_message")
                or update.get("channel_post")
                or update.get("edited_channel_post")
            ) if isinstance(update, Mapping) else None
            if isinstance(raw_message, Mapping):
                chat = raw_message.get("chat") if isinstance(raw_message.get("chat"), Mapping) else {}
                channel_id = str(chat.get("id") or "").strip()
                reply_to = raw_message.get("message_id")
                if channel_id:
                    self.gateway.send_message(
                        channel_id,
                        safe_message,
                        reply_to=str(reply_to) if reply_to is not None else None,
                    )
        except Exception:
            pass

    def run_once(self, poll_timeout: int = 30) -> int:
        try:
            updates = self.gateway.get_updates(
                offset=self.next_offset if self.next_offset > 0 else None,
                timeout=poll_timeout,
                allowed_updates=["message", "edited_message", "channel_post", "edited_channel_post", "callback_query"],
            )
        except Exception as exc:
            logger.error("Telegram polling failed: %s", exc)
            return 0
        processed = 0
        for upd in updates:
            update_id = upd.get("update_id")
            if isinstance(update_id, int) and update_id in self._recent_update_ids:
                self.next_offset = max(self.next_offset, update_id + 1)
                continue

            try:
                if self.process_update(upd) is not None:
                    processed += 1
                if isinstance(update_id, int):
                    self._recent_update_ids.append(update_id)
                    self.next_offset = max(self.next_offset, update_id + 1)
                    self._save_state()
            except Exception as exc:
                logger.error("Telegram update processing failed: %s", exc)
                self._audit("update_processing_failed", update_id=update_id, error=str(exc))
                self._best_effort_failure_reply(
                    upd,
                    f"Armorer hit an internal error while processing this request: {exc}",
                )
                if isinstance(update_id, int):
                    self._recent_update_ids.append(update_id)
                    self.next_offset = max(self.next_offset, update_id + 1)
                    self._save_state()
        return processed

    def run_forever(self, poll_timeout: int = 30, sleep_when_idle: float = 0.5) -> None:
        while True:
            processed = self.run_once(poll_timeout=poll_timeout)
            if processed == 0 and sleep_when_idle > 0:
                time.sleep(sleep_when_idle)


class SignalCoreBridge:
    """Polling Signal bridge that forwards messages to Armorer Core."""

    def __init__(
        self,
        gateway: SignalGateway,
        relay: InboxOutboxRelay,
        response_timeout: int = 300,
        state_file: Optional[Path] = None,
    ):
        self.gateway = gateway
        self.relay = relay
        self.response_timeout = response_timeout
        self.config = ConfigManager()
        self.state_file = state_file or (relay.config_dir / "signal_gateway_state.json")
        self._recent_event_ids: deque[str] = deque(maxlen=1024)
        self._audit_file = relay.config_dir / "security_audit.jsonl"
        self._pending_confirmations_file = relay.config_dir / "signal_pending_confirmations.json"
        self._rate_limit_events: dict[str, deque[float]] = {}
        self._pending_confirmations: dict[str, dict[str, Any]] = {}
        self._admin_user_ids: set[str] = set()
        self._admin_chat_ids: set[str] = set()
        self._allow_unconfigured_admins = False
        self._rate_limit_count = 15
        self._rate_limit_window_s = 60
        self._confirm_ttl_s = 60
        self._load_state()
        self._load_security_policy()
        self._load_confirmations()

    @staticmethod
    def _csv_set(raw: Optional[str]) -> set[str]:
        if not raw:
            return set()
        return {x.strip() for x in str(raw).split(",") if x.strip()}

    def _audit(self, event: str, **details: Any) -> None:
        row = {"ts": time.time(), "provider": "signal", "event": event, "details": details}
        try:
            self._audit_file.parent.mkdir(parents=True, exist_ok=True)
            with open(self._audit_file, "a") as f:
                f.write(json.dumps(row, sort_keys=True) + "\n")
        except Exception as exc:
            logger.warning("Failed writing security audit event: %s", exc)

    def _load_security_policy(self) -> None:
        admin_users = (
            self.config.get_key("ARMORER_SIGNAL_ADMIN_USER_IDS")
            or self.config.get_key("ARMORER_ADMIN_USER_IDS")
            or ""
        )
        admin_chats = (
            self.config.get_key("ARMORER_SIGNAL_ADMIN_CHAT_IDS")
            or self.config.get_key("ARMORER_ADMIN_CHAT_IDS")
            or ""
        )
        self._admin_user_ids = self._csv_set(admin_users)
        self._admin_chat_ids = self._csv_set(admin_chats)
        open_mode = (
            self.config.get_key("ARMORER_SIGNAL_ALLOW_UNCONFIGURED_ADMINS")
            or self.config.get_key("ARMORER_GATEWAY_ALLOW_UNCONFIGURED_ADMINS")
            or "false"
        )
        self._allow_unconfigured_admins = str(open_mode).strip().lower() in {"1", "true", "on", "yes"}

        try:
            self._rate_limit_count = max(1, int(self.config.get_key("ARMORER_SIGNAL_RATE_LIMIT_COUNT") or "15"))
        except Exception:
            self._rate_limit_count = 15
        try:
            self._rate_limit_window_s = max(10, int(self.config.get_key("ARMORER_SIGNAL_RATE_LIMIT_WINDOW_S") or "60"))
        except Exception:
            self._rate_limit_window_s = 60
        try:
            self._confirm_ttl_s = max(15, int(self.config.get_key("ARMORER_CONFIRM_TTL_S") or "60"))
        except Exception:
            self._confirm_ttl_s = 60

    def _load_confirmations(self) -> None:
        try:
            if self._pending_confirmations_file.exists():
                raw = json.loads(self._pending_confirmations_file.read_text())
                if isinstance(raw, dict):
                    self._pending_confirmations = raw
        except Exception as exc:
            logger.warning("Failed loading pending Signal confirmations: %s", exc)
            self._pending_confirmations = {}
        self._expire_confirmations()

    def _save_confirmations(self) -> None:
        try:
            self._pending_confirmations_file.parent.mkdir(parents=True, exist_ok=True)
            self._pending_confirmations_file.write_text(json.dumps(self._pending_confirmations, sort_keys=True))
        except Exception as exc:
            logger.warning("Failed saving pending Signal confirmations: %s", exc)

    def _expire_confirmations(self) -> None:
        now = time.time()
        expired = [tok for tok, row in self._pending_confirmations.items() if float(row.get("expires_at", 0)) < now]
        for tok in expired:
            self._pending_confirmations.pop(tok, None)
        if expired:
            self._save_confirmations()

    def _is_admin(self, user_id: str, channel_id: str) -> bool:
        if not self._admin_user_ids and not self._admin_chat_ids:
            return self._allow_unconfigured_admins
        return (user_id in self._admin_user_ids) or (channel_id in self._admin_chat_ids)

    def _check_rate_limit(self, user_id: str) -> bool:
        now = time.time()
        bucket = self._rate_limit_events.setdefault(user_id, deque())
        while bucket and (now - bucket[0]) > self._rate_limit_window_s:
            bucket.popleft()
        if len(bucket) >= self._rate_limit_count:
            return False
        bucket.append(now)
        return True

    @staticmethod
    def _requires_confirmation(prompt: str) -> bool:
        return TelegramCoreBridge._requires_confirmation(prompt)

    def _latest_confirmation(
        self,
        *,
        channel_id: str,
        user_id: str,
    ) -> list[tuple[str, dict[str, Any]]]:
        self._expire_confirmations()
        matches: list[tuple[str, dict[str, Any]]] = []
        for token, row in self._pending_confirmations.items():
            if str(row.get("channel_id")) != str(channel_id):
                continue
            if str(row.get("user_id")) != str(user_id):
                continue
            matches.append((token, row))
        matches.sort(key=lambda item: float(item[1].get("created_at", 0)))
        return matches

    def _issue_confirmation(
        self,
        channel_id: str,
        user_id: str,
        prompt: str,
        reply_to: Optional[str],
    ) -> dict[str, Any]:
        self._expire_confirmations()
        replaced = [token for token, _ in self._latest_confirmation(channel_id=channel_id, user_id=user_id)]
        for token in replaced:
            self._pending_confirmations.pop(token, None)
        token = uuid.uuid4().hex[:8]
        self._pending_confirmations[token] = {
            "channel_id": channel_id,
            "user_id": user_id,
            "prompt": prompt,
            "created_at": time.time(),
            "expires_at": time.time() + self._confirm_ttl_s,
        }
        self._save_confirmations()
        if replaced:
            self._audit(
                "confirmation_replaced",
                channel_id=channel_id,
                user_id=user_id,
                replaced=len(replaced),
            )
        self._audit("confirmation_issued", channel_id=channel_id, user_id=user_id, token=token)
        return self.gateway.send_message(
            channel_id,
            (
                "High-risk action detected.\n"
                f"Reply `confirm` or `cancel` within {self._confirm_ttl_s}s to act on this request.\n"
                f"Explicit approval also works: `/confirm {token}`."
            ),
            reply_to=reply_to,
        )

    def _handle_confirm_command(
        self,
        channel_id: str,
        user_id: str,
        text: str,
        reply_to: Optional[str],
    ) -> Optional[dict[str, Any]]:
        t = str(text or "").strip()
        lower = t.lower()
        token = ""
        row: Optional[dict[str, Any]] = None
        if lower.startswith("/confirm "):
            parts = t.split(None, 1)
            token = parts[1].strip() if len(parts) > 1 else ""
            row = self._pending_confirmations.get(token)
            if not row:
                return self.gateway.send_message(
                    channel_id,
                    "Invalid or expired confirmation token.",
                    reply_to=reply_to,
                )
            if str(row.get("channel_id")) != str(channel_id) or str(row.get("user_id")) != str(user_id):
                self._audit("confirmation_denied_mismatch", channel_id=channel_id, user_id=user_id, token=token)
                return self.gateway.send_message(
                    channel_id,
                    "Token does not match this user/chat.",
                    reply_to=reply_to,
                )
        elif lower in {"confirm", "cancel"}:
            matches = self._latest_confirmation(channel_id=channel_id, user_id=user_id)
            if not matches:
                return None
            if len(matches) > 1:
                return self.gateway.send_message(
                    channel_id,
                    "Multiple pending confirmations found. Use `/confirm <token>` from the latest prompt.",
                    reply_to=reply_to,
                )
            token, row = matches[-1]
            if lower == "cancel":
                self._pending_confirmations.pop(token, None)
                self._save_confirmations()
                self._audit("confirmation_cancelled", channel_id=channel_id, user_id=user_id, token=token)
                return self.gateway.send_message(channel_id, "Action cancelled.", reply_to=reply_to)
        else:
            return None
        if not row:
            return None
        if float(row.get("expires_at", 0)) < time.time():
            self._pending_confirmations.pop(token, None)
            self._save_confirmations()
            return self.gateway.send_message(channel_id, "Confirmation token expired.", reply_to=reply_to)
        prompt = str(row.get("prompt", "")).strip()
        self._pending_confirmations.pop(token, None)
        self._save_confirmations()
        self._audit("confirmation_approved", channel_id=channel_id, user_id=user_id, token=token)
        return self._submit_to_core(
            channel_id=channel_id,
            user_id=user_id,
            prompt=prompt,
            event_id="",
            message_id=reply_to,
        )

    def _submit_to_core(
        self,
        *,
        channel_id: str,
        user_id: str,
        prompt: str,
        event_id: str,
        message_id: Optional[str],
    ) -> dict[str, Any]:
        metadata = {
            "provider": "signal",
            "event_id": event_id,
            "channel_id": channel_id,
            "user_id": user_id,
            "message_id": message_id,
        }
        try:
            task_id = self.relay.submit_task(prompt=prompt, source="signal", metadata=metadata)
        except Exception as exc:
            self._audit("task_submit_failed", channel_id=channel_id, user_id=user_id, error=str(exc))
            safe_detail = _sanitize_bridge_user_text(str(exc).strip() or exc.__class__.__name__)[:300]
            return self.gateway.send_message(channel_id, f"Failed to contact Armorer Core: {safe_detail}", reply_to=message_id)
        self._audit("task_submitted", channel_id=channel_id, user_id=user_id, task_id=task_id)
        cleanup_note = format_queue_cleanup_note(getattr(self.relay, "last_submit_cleanup", []))
        if cleanup_note:
            try:
                self.gateway.send_message(channel_id, cleanup_note, reply_to=message_id)
            except Exception as exc:
                logger.debug("Failed to send Signal cleanup note: %s", exc)

        def _status_update(msg: str) -> None:
            note = format_progress_note(msg)
            if not note:
                return
            try:
                self.gateway.send_message(channel_id, note, reply_to=message_id)
            except Exception as exc:
                logger.debug("Failed to send Signal progress update: %s", exc)

        try:
            result = self.relay.wait_for_response(
                task_id,
                timeout=self.response_timeout,
                status_callback=_status_update,
                include_ui_hints=True,
            )
            reply_text = result.get("message") or "No response generated."
        except Exception as exc:
            self._audit("task_wait_failed", channel_id=channel_id, user_id=user_id, task_id=task_id, error=str(exc))
            safe_detail = _sanitize_bridge_user_text(str(exc).strip() or exc.__class__.__name__)[:300]
            reply_text = f"Failed to contact Armorer Core: {safe_detail}"
        return self.gateway.send_message(channel_id, reply_text, reply_to=message_id)

    def _best_effort_failure_reply(self, raw_event: Mapping[str, Any], message: str) -> Optional[str]:
        safe_message = _sanitize_bridge_user_text(str(message or "")).strip()[:600]
        if not safe_message:
            safe_message = "Armorer hit an internal error while processing this request."
        channel_id = ""
        message_id: Optional[str] = None
        event_id = ""
        try:
            event = self.gateway.normalize_event(raw_event)
        except Exception:
            event = None
        if event is not None:
            channel_id = str(event.channel_id or "").strip()
            message_id = event.message_id
            event_id = str(event.event_id or "").strip()
        else:
            envelope = raw_event.get("envelope") if isinstance(raw_event, Mapping) else None
            data_message = envelope.get("dataMessage") if isinstance(envelope, Mapping) else None
            channel_id = str(
                (envelope.get("sourceNumber") if isinstance(envelope, Mapping) else None)
                or (envelope.get("source") if isinstance(envelope, Mapping) else None)
                or ""
            ).strip()
            if isinstance(data_message, Mapping) and data_message.get("timestamp") is not None:
                message_id = str(data_message.get("timestamp"))
            elif isinstance(envelope, Mapping) and envelope.get("timestamp") is not None:
                message_id = str(envelope.get("timestamp"))
            event_id = str(
                ((envelope.get("timestamp") if isinstance(envelope, Mapping) else None))
                or ((data_message.get("timestamp") if isinstance(data_message, Mapping) else None))
                or ""
            ).strip()
        if not channel_id:
            return event_id or None
        try:
            self.gateway.send_message(channel_id, safe_message, reply_to=message_id)
        except Exception:
            return event_id or None
        return event_id or None

    def _load_state(self) -> None:
        try:
            if not self.state_file.exists():
                return
            data = json.loads(self.state_file.read_text())
            if isinstance(data, dict):
                recent = data.get("recent_event_ids", [])
                if isinstance(recent, list):
                    for item in recent[-1024:]:
                        if isinstance(item, str):
                            self._recent_event_ids.append(item)
        except Exception as exc:
            logger.warning("Failed loading Signal gateway state: %s", exc)

    def _save_state(self) -> None:
        try:
            payload = {
                "recent_event_ids": list(self._recent_event_ids),
                "updated_at": time.time(),
            }
            self.state_file.write_text(json.dumps(payload))
        except Exception as exc:
            logger.warning("Failed saving Signal gateway state: %s", exc)

    def _mark_event_processed(self, event_id: str) -> None:
        eid = str(event_id or "").strip()
        if not eid or eid in self._recent_event_ids:
            return
        self._recent_event_ids.append(eid)
        self._save_state()

    def process_event(self, raw_event: Mapping[str, Any]) -> Optional[dict[str, Any]]:
        event = self.gateway.normalize_event(raw_event)
        if event is None or not event.text.strip():
            return None
        if event.event_id and event.event_id in self._recent_event_ids:
            return None
        if not self._is_admin(event.user_id, event.channel_id):
            self._audit("authz_denied", channel_id=event.channel_id, user_id=event.user_id)
            out = self.gateway.send_message(
                event.channel_id,
                (
                    "Access denied. Configure ARMORER_SIGNAL_ADMIN_USER_IDS/CHAT_IDS "
                    "or explicitly enable ARMORER_SIGNAL_ALLOW_UNCONFIGURED_ADMINS=true."
                ),
                reply_to=event.message_id,
            )
            self._mark_event_processed(event.event_id)
            return out
        if not self._check_rate_limit(event.user_id):
            self._audit("rate_limited", channel_id=event.channel_id, user_id=event.user_id)
            out = self.gateway.send_message(
                event.channel_id,
                f"Rate limit exceeded. Max {self._rate_limit_count} requests/{self._rate_limit_window_s}s.",
                reply_to=event.message_id,
            )
            self._mark_event_processed(event.event_id)
            return out

        confirmed = self._handle_confirm_command(
            channel_id=event.channel_id,
            user_id=event.user_id,
            text=event.text,
            reply_to=event.message_id,
        )
        if confirmed is not None:
            self._mark_event_processed(event.event_id)
            return confirmed
        if self._requires_confirmation(event.text):
            out = self._issue_confirmation(
                channel_id=event.channel_id,
                user_id=event.user_id,
                prompt=event.text,
                reply_to=event.message_id,
            )
            self._mark_event_processed(event.event_id)
            return out

        out = self._submit_to_core(
            channel_id=event.channel_id,
            user_id=event.user_id,
            prompt=event.text,
            event_id=event.event_id,
            message_id=event.message_id,
        )
        self._mark_event_processed(event.event_id)
        return out

    def run_once(self, receive_timeout: int = 1, max_messages: int = 10) -> int:
        try:
            events = self.gateway.receive_messages(timeout=receive_timeout, max_messages=max_messages)
        except Exception as exc:
            logger.error("Signal polling failed: %s", exc)
            return 0

        processed = 0
        for raw_event in events:
            try:
                if self.process_event(raw_event) is not None:
                    processed += 1
            except Exception as exc:
                logger.error("Signal event processing failed: %s", exc)
                self._audit("event_processing_failed", error=str(exc))
                event_id = self._best_effort_failure_reply(
                    raw_event,
                    f"Armorer hit an internal error while processing this request: {exc}",
                )
                if event_id:
                    self._recent_event_ids.append(event_id)
                    self._save_state()
        return processed

    def run_forever(self, receive_timeout: int = 1, max_messages: int = 10, sleep_when_idle: float = 0.5) -> None:
        while True:
            processed = self.run_once(receive_timeout=receive_timeout, max_messages=max_messages)
            if processed == 0 and sleep_when_idle > 0:
                time.sleep(sleep_when_idle)
