"""
Behavior Policy v2.2: SOWA with 3-level response (FULL / ACK / SILENT)
Уніфікована логіка для всіх агентів НОДА1.

Aligned with Global System Prompt v2.2

v2.2 Changes (from v2.1):
- 3-level output: FULL (LLM response), ACK (short presence message, no LLM), SILENT (no output)
- Mention classification: direct / soft / topic
- Direct mention without explicit_request → ACK (was: SILENT in v2.1)
- Soft mention in short message → ACK (with cooldown)
- Soft mention in long/broadcast message → SILENT
- ACK cooldown per (agent, chat): prevents spam
- ACK burst limit per chat
- Enhanced broadcast detection (length + patterns)
- Backward compatible: should_respond() still returns (bool, str)

Правила:
1. SOWA (Speak-Only-When-Asked) — не відповідай повністю, якщо не питали
2. ACK presence — на пряму згадку коротко відповідай "я тут"
3. Short-First — 1-2 речення за замовчуванням
4. Media-no-comment — медіа без питання = мовчанка
5. Conversation Context — якщо користувач вже звертався до агента, продовжуй розмову
"""
import re
import logging
import time
from typing import Dict, Any, Optional, List, Tuple
from dataclasses import dataclass, field
from enum import Enum

logger = logging.getLogger(__name__)

# ========================================
# Constants
# ========================================

# Marker for "no response needed"
NO_OUTPUT = "__NO_OUTPUT__"

# 3-level action enum
class SowaAction(str, Enum):
    FULL = "FULL"      # Full LLM response
    ACK = "ACK"        # Short presence message (no LLM call)
    SILENT = "SILENT"  # No output


# Training groups where agents respond to ALL messages
TRAINING_GROUP_IDS = {
    "-1003556680911",  # Agent Preschool Daarion.city
}

# Conversation context timeout (seconds)
# v2.2: increased from 5min to 30min — users in group chats often pause between messages
CONVERSATION_CONTEXT_TIMEOUT = 1800  # 30 minutes

# ACK cooldown settings
ACK_COOLDOWN_DIRECT = 300   # 5 min cooldown for direct mention ACK per (agent, chat)
ACK_COOLDOWN_SOFT = 1800    # 30 min cooldown for soft mention ACK per (agent, chat)
ACK_BURST_LIMIT = 3         # Max ACK per chat within burst window
ACK_BURST_WINDOW = 600      # 10 min burst window

# Soft mention: only ACK if message is short enough
SOFT_MENTION_MAX_LEN = 220

# Broadcast detection: long messages are likely announcements
BROADCAST_MIN_LEN = 280

# ACK templates per agent (short, no LLM needed)
ACK_TEMPLATES: Dict[str, str] = {
    "default": "Я тут. Якщо потрібна відповідь — запитай або напиши дію.",
    "helion": "Helion на зв'язку. Запитай — і я відповім.",
    "daarwizz": "DAARWIZZ тут. Чим можу допомогти?",
    "greenfood": "GREENFOOD на зв'язку. Запитай — відповім.",
    "agromatrix": "AgroMatrix тут. Чекаю на ваше питання.",
    "nutra": "NUTRA тут. Запитайте — і я відповім.",
    "alateya": "Alateya на зв'язку. Чим можу допомогти?",
    "druid": "DRUID тут. Задай питання — і я відповім.",
    "clan": "Spirit на зв'язку. Запитай — відповім.",
    "eonarch": "EONARCH тут. Чекаю на ваше питання.",
    "senpai": "SENPAI на зв'язку. Запитайте — допоможу.",
    "oneok": "1OK на зв'язку. Готовий допомогти з вікнами, заміром і КП.",
}


def get_ack_text(agent_id: str) -> str:
    """Get ACK template for agent."""
    return ACK_TEMPLATES.get(agent_id, ACK_TEMPLATES["default"])


# ========================================
# State: conversation context + ACK cooldown
# ========================================

# In-memory conversation context: {(agent_id, chat_id, user_id): last_interaction_timestamp}
_conversation_context: Dict[Tuple[str, str, str], float] = {}

# ACK cooldown: {(agent_id, chat_id): [list of ack timestamps]}
_ack_cooldown: Dict[Tuple[str, str], List[float]] = {}


def record_interaction(agent_id: str, chat_id: str, user_id: str) -> None:
    """Record that user interacted with agent (agent responded)."""
    key = (agent_id, str(chat_id), str(user_id))
    _conversation_context[key] = time.time()
    logger.debug(f"Recorded interaction: {key}")


def has_recent_interaction(agent_id: str, chat_id: str, user_id: str) -> bool:
    """Check if user has recent interaction with agent in this chat."""
    key = (agent_id, str(chat_id), str(user_id))
    last_time = _conversation_context.get(key)
    if last_time is None:
        return False
    elapsed = time.time() - last_time
    is_recent = elapsed < CONVERSATION_CONTEXT_TIMEOUT
    if is_recent:
        logger.debug(f"Recent interaction found: {key}, {elapsed:.0f}s ago")
    return is_recent


def has_agent_chat_participation(agent_id: str, chat_id: str) -> bool:
    """
    Check if agent has responded to ANY user in this chat recently.
    Used for thread_has_agent_participation (SOWA Priority 11).
    
    Different from has_recent_interaction():
    - has_recent_interaction: THIS user talked to agent (per-user)
    - has_agent_chat_participation: agent talked to ANYONE in this chat (per-chat)
    """
    now = time.time()
    str_chat_id = str(chat_id)
    for (aid, cid, _uid), timestamp in _conversation_context.items():
        if aid == agent_id and cid == str_chat_id:
            if now - timestamp < CONVERSATION_CONTEXT_TIMEOUT:
                return True
    return False


def cleanup_old_contexts() -> None:
    """Remove old conversation contexts and ACK cooldowns to prevent memory leak."""
    global _conversation_context, _ack_cooldown
    now = time.time()
    cutoff_ctx = now - CONVERSATION_CONTEXT_TIMEOUT * 2
    cutoff_ack = now - max(ACK_COOLDOWN_SOFT, ACK_BURST_WINDOW) * 2

    old_ctx = len(_conversation_context)
    _conversation_context = {k: v for k, v in _conversation_context.items() if v > cutoff_ctx}
    if old_ctx != len(_conversation_context):
        logger.debug(f"Cleaned up {old_ctx - len(_conversation_context)} old conversation contexts")

    old_ack = len(_ack_cooldown)
    new_ack = {}
    for k, timestamps in _ack_cooldown.items():
        fresh = [t for t in timestamps if t > cutoff_ack]
        if fresh:
            new_ack[k] = fresh
    _ack_cooldown = new_ack
    if old_ack != len(_ack_cooldown):
        logger.debug(f"Cleaned up {old_ack - len(_ack_cooldown)} old ACK cooldown entries")


def check_ack_cooldown(agent_id: str, chat_id: str, mention_type: str) -> bool:
    """
    Check if ACK is allowed (not in cooldown).
    Returns True if ACK can be sent, False if blocked by cooldown.
    """
    key = (agent_id, str(chat_id))
    now = time.time()
    timestamps = _ack_cooldown.get(key, [])

    # Check cooldown based on mention type
    cooldown = ACK_COOLDOWN_DIRECT if mention_type == "direct" else ACK_COOLDOWN_SOFT
    if timestamps:
        last_ack = max(timestamps)
        if now - last_ack < cooldown:
            logger.debug(f"ACK cooldown active for {key}: {now - last_ack:.0f}s < {cooldown}s")
            return False

    # Check burst limit
    recent_count = sum(1 for t in timestamps if now - t < ACK_BURST_WINDOW)
    if recent_count >= ACK_BURST_LIMIT:
        logger.debug(f"ACK burst limit reached for {key}: {recent_count} >= {ACK_BURST_LIMIT}")
        return False

    return True


def record_ack(agent_id: str, chat_id: str) -> None:
    """Record that an ACK was sent."""
    key = (agent_id, str(chat_id))
    now = time.time()
    if key not in _ack_cooldown:
        _ack_cooldown[key] = []
    _ack_cooldown[key].append(now)
    logger.debug(f"Recorded ACK for {key}")


def is_prober_request(chat_id: str, user_id: str) -> bool:
    """Check if this is a prober/test request (chat_id=0 or user_id=0)."""
    return str(chat_id) == "0" or str(user_id) == "0"


# ========================================
# URL Detection (single source of truth — gateway uses this)
# ========================================
URL_RE = re.compile(r"(https?://|www\.|t\.me/|telegram\.me/)", re.IGNORECASE)


def detect_url(text: str) -> bool:
    """Detect URLs in text. Used by gateway as single source of truth."""
    if not text:
        return False
    return bool(URL_RE.search(text))


# ========================================
# Imperative / Explicit Request Detection (gateway uses this)
# ========================================
IMPERATIVE_RE = re.compile(
    r"\b("
    # Ukrainian imperatives (comprehensive)
    r"допоможи|поясни|проаналізуй|перевір|виправ|створи|зроби|опиши|розпиши|"
    r"розкажи|знайди|аналізуй|порівняй|підсумуй|витягни|переклади|напиши|"
    r"згенеруй|порахуй|покажи|дай|скажи|оціни|підкажи|скинь|"
    # Missing Ukrainian imperatives (real user commands)
    r"зафіксуй|вивчи|запамятай|запам'ятай|збережи|подивись|прочитай|перечитай|переглянь|"
    r"відповідай|відповідь|додай|видали|оновити|онови|оновлюй|"
    r"завантаж|відправ|надішли|перешли|скопіюй|"
    r"підготуй|розрахуй|спрогнозуй|спланируй|сплануй|запусти|зупини|"
    r"нагадай|повідом|попередь|сповісти|"
    r"протестуй|відстежуй|моніторь|моніториш|"
    r"розклади|систематизуй|структуруй|категоризуй|"
    r"засвоїв|засвой|вивчай|досліди|дослідж|"
    # English imperatives
    r"help|explain|analyze|check|fix|create|make|describe|list|"
    r"find|compare|summarize|extract|translate|write|generate|calculate|show|give|tell|"
    r"remember|save|add|remove|delete|update|send|forward|read|look|watch|"
    r"monitor|track|plan|schedule|start|stop|test|"
    # Russian imperatives
    r"помоги|объясни|проанализируй|проверь|исправь|создай|сделай|опиши|"
    r"зафиксируй|изучи|запомни|сохрани|посмотри|прочитай|ответь|добавь|"
    # Phrase patterns (Ukrainian)
    r"що думаєш|можеш|потрібно|будь ласка|скажи будь|як вважаєш|як думаєш"
    r")\b",
    re.IGNORECASE
)


def detect_explicit_request(
    text: str,
    is_dm: bool = False,
    is_reply_to_agent: bool = False,
    mentioned_agents: Optional[List[str]] = None,
    thread_has_agent_participation: bool = False,
) -> bool:
    """
    Detect if message contains an explicit request.
    Used by gateway as single source of truth.

    CONTRACT (do not change without updating Global System Prompt v2.2 + tests):

        has_explicit_request = imperative
            OR (question_mark AND (is_dm
                                   OR is_reply_to_agent
                                   OR mentioned_agents not empty
                                   OR thread_has_agent_participation))
    """
    if not text:
        return False
    if IMPERATIVE_RE.search(text):
        return True
    has_question = '?' in text
    has_mention = bool(mentioned_agents)
    question_with_context = has_question and (
        is_dm or is_reply_to_agent or has_mention or thread_has_agent_participation
    )
    return question_with_context


# ========================================
# Agent name variants for mention detection
# ========================================
AGENT_NAME_VARIANTS: Dict[str, List[str]] = {
    "helion": ["helion", "хеліон", "хелион", "hélion", "helios", "@energyunionbot"],
    "daarwizz": ["daarwizz", "даарвіз", "даарвиз", "@daarwizzbot"],
    "greenfood": ["greenfood", "грінфуд", "гринфуд", "@greenfoodlivebot"],
    "agromatrix": ["agromatrix", "агроматрікс", "агроматрикс", "@agromatrixbot", "степан"],
    "alateya": ["alateya", "алатея", "@alateyabot"],
    "nutra": ["nutra", "нутра", "@nutrachat_bot"],
    "druid": ["druid", "друїд", "друид", "@druidbot"],
    "clan": ["clan", "spirit", "клан", "спіріт", "спирит", "@clanbot"],
    "eonarch": ["eonarch", "еонарх", "@eonarchbot"],
    "senpai": ["senpai", "сенпай", "сэнпай", "гордон", "gordon", "@senpai_agent_bot"],
    "oneok": ["oneok", "1ok", "1ок", "одинок", "асистент віконного майстра", "@oneokbot"],
    "soul": ["soul", "athena", "атена", "афіна", "афина", "@athena_soul_bot"],
    "yaromir": ["yaromir", "яромир", "@yaromir_agent_bot"],
    "sofiia": ["sofiia", "софія", "софия", "софія", "@sofiia_agent_bot"],
}

# Commands that trigger response
COMMAND_PREFIXES = [
    "/ask", "/agent", "/help", "/start", "/status", "/link",
    "/daarwizz", "/helion", "/greenfood", "/agromatrix", "/alateya",
    "/nutra", "/druid", "/clan", "/eonarch", "/senpai", "/oneok", "/sofiia",
    "/ingest", "/бренд", "/презентація", "/job", "/soul", "/athena", "/yaromir",
]

# Broadcast/poster patterns
BROADCAST_PATTERNS = [
    r"^\d{1,2}[:.]\d{2}\s",  # Time pattern: "20:00", "14.30"
    r"^\d{1,2}[:.]\d{2}\s+\d{1,2}[./]\d{1,2}",  # "20:00 10.02"
    r"^[\u2705\u274c\u23f0\u2b50\u26a1\u2764]",  # Starts with common emoji
    r"^(анонс|запрошуємо|нагадуємо|увага|важливо|news|update|alert)",  # Announcement words
    r"^https?://",  # URL only
    r"#\w+.*#\w+",  # Multiple hashtags
]

# Short note patterns (timing, reactions, status updates)
SHORT_NOTE_PATTERNS = [
    r"^[\u2705\u274c\u2611\u2612]+$",  # Only checkmarks
    r"^\d{1,2}[:.]\d{2}(\s+\d{1,2}[./]\d{1,2})?\s*[\u2705\u274c]?$",  # "20:00 10.02 ✅"
    r"^[+\-ok\u2705\u274c]{1,3}$",  # +, -, ok, ✅, ❌
    r"^(ok|ок|добре|так|ні|yes|no|done|готово)$",  # Short confirmations
]


# ========================================
# BehaviorDecision
# ========================================

@dataclass
class BehaviorDecision:
    """Result of behavior analysis with 3-level action."""
    should_respond: bool
    reason: str
    action: str = "SILENT"  # FULL / ACK / SILENT
    ack_text: str = ""  # Pre-built ACK text (only when action=ACK)
    mention_type: str = ""  # direct / soft / none
    is_training_group: bool = False
    is_direct_mention: bool = False
    is_command: bool = False
    is_question: bool = False
    is_imperative: bool = False
    is_broadcast: bool = False
    is_short_note: bool = False
    has_media: bool = False
    media_has_question: bool = False
    has_conversation_context: bool = False
    is_prober: bool = False
    has_explicit_request: bool = False


# ========================================
# Detection functions
# ========================================

def _normalize_text(text: str) -> str:
    """Normalize text for pattern matching"""
    if not text:
        return ""
    return text.lower().strip()


def detect_agent_mention(text: str, agent_id: str) -> bool:
    """Check if message mentions the agent."""
    if not text:
        return False
    normalized = _normalize_text(text)
    variants = AGENT_NAME_VARIANTS.get(agent_id, [agent_id])
    for variant in variants:
        if variant.lower() in normalized:
            return True
    return False


def detect_any_agent_mention(text: str) -> Optional[str]:
    """Check if message mentions any agent. Returns agent_id or None."""
    if not text:
        return None
    normalized = _normalize_text(text)
    for agent_id, variants in AGENT_NAME_VARIANTS.items():
        for variant in variants:
            if variant.lower() in normalized:
                return agent_id
    return None


def classify_mention_type(text: str, agent_id: str) -> str:
    """
    Classify how the agent is mentioned in the message.

    Returns:
        "direct" — @bot, reply-to, agent name at start, "Agent, ...", "Гей Agent"
        "soft"   — agent name appears in the middle/body of text
        "none"   — not mentioned
    """
    if not text:
        return "none"

    normalized = _normalize_text(text)
    variants = AGENT_NAME_VARIANTS.get(agent_id, [agent_id])

    # Find which variant matched
    matched_variant = None
    match_pos = -1
    for variant in variants:
        pos = normalized.find(variant.lower())
        if pos != -1:
            matched_variant = variant
            match_pos = pos
            break

    if matched_variant is None:
        return "none"

    # Direct mention patterns:
    # 1. Starts with @ (e.g. @Helion)
    if matched_variant.startswith("@"):
        return "direct"

    # 2. Agent name is at the very beginning (first 25 chars)
    if match_pos <= 5:
        return "direct"

    # 3. Pattern: "Agent," or "Agent:" or "Agent —" at start
    prefix = normalized[:match_pos + len(matched_variant) + 3]
    if re.match(r"^[\s]*\S+[\s]*[,:\-—]", prefix):
        return "direct"

    # 4. Addressing patterns: "Гей Agent", "Hey Agent", "привіт Agent"
    before = normalized[:match_pos].strip()
    if before and re.search(r"\b(гей|hey|привіт|привет|hi|hello|yo|ей)\s*$", before, re.IGNORECASE):
        return "direct"

    # Everything else is a soft mention (name appears in body of text)
    return "soft"


def detect_command(text: str) -> bool:
    """Check if message starts with a command"""
    if not text:
        return False
    stripped = text.strip()
    for prefix in COMMAND_PREFIXES:
        if stripped.lower().startswith(prefix):
            return True
    return False


def detect_question(text: str) -> bool:
    """Check if message contains a question mark."""
    if not text:
        return False
    return '?' in text


def detect_imperative(text: str) -> bool:
    """Check if message contains an imperative (command/request)."""
    if not text:
        return False
    return bool(IMPERATIVE_RE.search(text))


def detect_broadcast_intent(text: str) -> bool:
    """
    Check if message is a broadcast/announcement/poster.
    Enhanced in v2.2: also checks message length.
    """
    if not text:
        return False

    stripped = text.strip()

    # Check explicit broadcast patterns
    for pattern in BROADCAST_PATTERNS:
        if re.match(pattern, stripped, re.IGNORECASE | re.UNICODE):
            logger.debug(f"Broadcast pattern matched: {pattern}")
            return True

    # Very short messages with only emojis/special chars
    if len(stripped) <= 5 and not any(c.isalpha() for c in stripped):
        return True

    # v2.2: Long messages with multiple paragraphs/links are likely announcements
    if len(stripped) > BROADCAST_MIN_LEN:
        # Check for low "addressedness": no second-person markers
        has_address = bool(re.search(
            r"\b(ти|ви|тобі|вам|тебе|вас|підкажи|можеш|скажи|допоможи|you|your)\b",
            stripped, re.IGNORECASE
        ))
        has_multiple_links = len(URL_RE.findall(stripped)) >= 2
        has_multiple_paragraphs = stripped.count('\n') >= 3

        if not has_address and (has_multiple_links or has_multiple_paragraphs):
            logger.debug(f"Broadcast detected: long ({len(stripped)} chars), low addressedness")
            return True

    return False


def detect_short_note(text: str) -> bool:
    """Check if message is a short note without request."""
    if not text:
        return True
    stripped = text.strip()
    if len(stripped) <= 10:
        for pattern in SHORT_NOTE_PATTERNS:
            if re.match(pattern, stripped, re.IGNORECASE | re.UNICODE):
                logger.debug(f"Short note pattern matched: {pattern}")
                return True
    return False


def detect_media_question(caption: str) -> bool:
    """Check if media caption contains a question/request."""
    if not caption:
        return False
    if detect_question(caption):
        return True
    if detect_imperative(caption):
        return True
    return False


# ========================================
# Main analysis function
# ========================================

def analyze_message(
    text: str,
    agent_id: str,
    chat_id: str,
    user_id: str = "",
    has_media: bool = False,
    media_caption: str = "",
    is_private_chat: bool = False,
    payload_explicit_request: bool = False,
    payload_has_link: bool = False,
    is_reply_to_agent: bool = False,
    thread_has_agent_participation: bool = False,
) -> BehaviorDecision:
    """
    Main function: analyze message and decide FULL / ACK / SILENT.

    v2.2 changes:
    - Returns action field: FULL, ACK, or SILENT
    - Direct mention without explicit_request → ACK (was: SILENT in v2.1)
    - Soft mention in short message → ACK (with cooldown)
    - Enhanced broadcast detection
    - ACK cooldown to prevent spam

    Backward compatible: should_respond=True means FULL or ACK,
    should_respond=False means SILENT. Check decision.action for the level.
    """
    # Periodic cleanup
    cleanup_old_contexts()

    # Merge has_media with has_link
    effective_has_media = has_media or payload_has_link

    decision = BehaviorDecision(
        should_respond=False,
        reason="",
        action="SILENT",
        is_training_group=str(chat_id) in TRAINING_GROUP_IDS,
        has_media=effective_has_media,
        is_prober=is_prober_request(chat_id, user_id),
        has_explicit_request=payload_explicit_request,
    )

    # --- Priority 0: Prober ---
    if decision.is_prober:
        decision.should_respond = True
        decision.action = "FULL"
        decision.reason = "prober_request"
        return decision

    # --- Priority 1: Training groups ---
    if decision.is_training_group:
        # In training chats, do NOT auto-FULL for every agent.
        # Only the targeted agent (mention/reply) should respond.
        if is_reply_to_agent:
            decision.should_respond = True
            decision.action = "FULL"
            decision.reason = "training_reply_to_agent"
            return decision
        targeted_agent = detect_any_agent_mention(text)
        if targeted_agent and targeted_agent != agent_id:
            decision.should_respond = False
            decision.action = "SILENT"
            decision.reason = f"training_addressed_to_other_agent_{targeted_agent}"
            return decision
        # If targeted_agent == agent_id or no target, continue with standard logic below.

    # --- Priority 2: Private chat (DM) ---
    if is_private_chat:
        decision.should_respond = True
        decision.action = "FULL"
        decision.reason = "private_chat"
        return decision

    # --- Priority 3: Reply to agent ---
    if is_reply_to_agent:
        decision.should_respond = True
        decision.action = "FULL"
        decision.reason = "reply_to_agent"
        return decision

    # --- Gather signals ---
    decision.has_conversation_context = has_recent_interaction(agent_id, chat_id, user_id)
    decision.is_direct_mention = detect_agent_mention(text, agent_id)
    decision.is_command = detect_command(text)
    decision.is_question = detect_question(text)
    decision.is_imperative = detect_imperative(text)
    decision.is_broadcast = detect_broadcast_intent(text)
    decision.is_short_note = detect_short_note(text)

    # Classify mention type (direct / soft / none)
    if decision.is_direct_mention:
        decision.mention_type = classify_mention_type(text, agent_id)
    else:
        decision.mention_type = "none"

    # --- Priority 4: Media/link handling ---
    if effective_has_media:
        decision.media_has_question = detect_media_question(media_caption)

        if decision.media_has_question or payload_explicit_request:
            # Media with question/request = FULL
            if decision.is_direct_mention or decision.has_conversation_context:
                decision.should_respond = True
                decision.action = "FULL"
                decision.reason = "media_with_question"
                return decision
            # Media with question but not addressed to this agent
            decision.should_respond = False
            decision.action = "SILENT"
            decision.reason = "media_question_not_directed"
            return decision

        # Media without question
        if decision.has_conversation_context:
            decision.should_respond = True
            decision.action = "FULL"
            decision.reason = "media_conversation_context"
            return decision

        decision.should_respond = False
        decision.action = "SILENT"
        decision.reason = "media_or_link_without_request"
        return decision

    # --- Priority 5: Command ---
    if decision.is_command:
        decision.should_respond = True
        decision.action = "FULL"
        decision.reason = "command"
        return decision

    # --- Priority 6: Broadcast ---
    if decision.is_broadcast:
        if not decision.is_direct_mention:
            decision.should_respond = False
            decision.action = "SILENT"
            decision.reason = "broadcast_not_directed"
            return decision
        # Broadcast WITH direct mention + explicit_request → FULL
        if payload_explicit_request:
            decision.should_respond = True
            decision.action = "FULL"
            decision.reason = "broadcast_with_request"
            return decision
        # Broadcast WITH mention but no request → SILENT (too noisy to ACK broadcasts)
        decision.should_respond = False
        decision.action = "SILENT"
        decision.reason = "broadcast_mention_no_request"
        return decision

    # --- Priority 7: Short note ---
    if decision.is_short_note:
        decision.should_respond = False
        decision.action = "SILENT"
        decision.reason = "short_note"
        return decision

    # --- Priority 8: Direct mention with explicit_request → FULL ---
    if decision.mention_type == "direct" and payload_explicit_request:
        decision.should_respond = True
        decision.action = "FULL"
        decision.reason = "mentioned_with_request"
        return decision

    # --- Priority 9: Direct mention without explicit_request ---
    if decision.mention_type == "direct":
        # v2.3: If message is substantial (>40 chars after removing agent name), give FULL response
        # Users often address agents with statements, not just questions
        clean_text = text or ""
        for variant in AGENT_NAME_VARIANTS.get(agent_id, [agent_id]):
            clean_text = clean_text.lower().replace(variant.lower(), "").strip()
        clean_text = clean_text.strip(" ,.:!;-—")
        
        if len(clean_text) > 40:
            decision.should_respond = True
            decision.action = "FULL"
            decision.reason = "direct_mention_substantial_text"
            logger.info(f"✅ SOWA v2.3: Direct mention + substantial text ({len(clean_text)} chars) → FULL for {agent_id}")
            return decision
        
        # Short mention without request → ACK
        if check_ack_cooldown(agent_id, chat_id, "direct"):
            decision.should_respond = True
            decision.action = "ACK"
            decision.ack_text = get_ack_text(agent_id)
            decision.reason = "direct_mention_ack"
            logger.info(f"👋 SOWA v2.3: Direct mention ACK for agent {agent_id} in chat {chat_id}")
            return decision
        else:
            decision.should_respond = False
            decision.action = "SILENT"
            decision.reason = "direct_mention_cooldown"
            logger.debug(f"ACK blocked by cooldown for {agent_id} in chat {chat_id}")
            return decision

    # --- Priority 10: Soft mention handling (v2.2) ---
    if decision.mention_type == "soft":
        msg_len = len(text) if text else 0

        # Soft mention with explicit_request → FULL
        if payload_explicit_request:
            decision.should_respond = True
            decision.action = "FULL"
            decision.reason = "soft_mention_with_request"
            return decision

        # Soft mention in short message → ACK (with cooldown)
        if msg_len <= SOFT_MENTION_MAX_LEN:
            if check_ack_cooldown(agent_id, chat_id, "soft"):
                decision.should_respond = True
                decision.action = "ACK"
                decision.ack_text = get_ack_text(agent_id)
                decision.reason = "soft_mention_short_ack"
                logger.info(f"👋 SOWA v2.2: Soft mention ACK for agent {agent_id} (msg len={msg_len})")
                return decision
            else:
                decision.should_respond = False
                decision.action = "SILENT"
                decision.reason = "soft_mention_cooldown"
                return decision

        # Soft mention in long message → SILENT
        decision.should_respond = False
        decision.action = "SILENT"
        decision.reason = "soft_mention_long_text"
        return decision

    # --- Priority 11: Thread participation ---
    if thread_has_agent_participation and (decision.is_question or decision.is_imperative):
        decision.should_respond = True
        decision.action = "FULL"
        decision.reason = "thread_participation"
        return decision

    # --- Priority 12: Conversation context ---
    if decision.has_conversation_context:
        other_agent = detect_any_agent_mention(text)
        if other_agent and other_agent != agent_id:
            decision.should_respond = False
            decision.action = "SILENT"
            decision.reason = f"addressed_to_other_agent_{other_agent}"
            return decision
        
        # v2.3: In active conversation, respond to questions, imperatives,
        # AND any substantial text (>30 chars). Natural conversation flow.
        msg_len = len(text.strip()) if text else 0
        if decision.is_question or decision.is_imperative or msg_len > 30:
            decision.should_respond = True
            decision.action = "FULL"
            decision.reason = "conversation_context_active"
            logger.info(f"✅ SOWA v2.3: Conversation context active for {agent_id}, msg_len={msg_len}")
            return decision

    # --- Default: SILENT ---
    decision.should_respond = False
    decision.action = "SILENT"
    decision.reason = "not_directed_to_agent"
    return decision


# ========================================
# Backward-compatible wrapper
# ========================================

def should_respond(
    text: str,
    agent_id: str,
    chat_id: str,
    user_id: str = "",
    has_media: bool = False,
    media_caption: str = "",
    is_private_chat: bool = False,
    payload_explicit_request: bool = False,
    payload_has_link: bool = False,
    is_reply_to_agent: bool = False,
    thread_has_agent_participation: bool = False,
) -> Tuple[bool, str]:
    """
    Backward-compatible wrapper. Returns (should_respond, reason).
    For ACK support, use analyze_message() directly and check decision.action.
    """
    decision = analyze_message(
        text=text,
        agent_id=agent_id,
        chat_id=chat_id,
        user_id=user_id,
        has_media=has_media,
        media_caption=media_caption,
        is_private_chat=is_private_chat,
        payload_explicit_request=payload_explicit_request,
        payload_has_link=payload_has_link,
        is_reply_to_agent=is_reply_to_agent,
        thread_has_agent_participation=thread_has_agent_participation,
    )
    return decision.should_respond, decision.reason


# ========================================
# LLM response analysis
# ========================================

def is_no_output_response(text: str) -> bool:
    """Check if LLM response indicates no output needed."""
    if not text:
        return True
    stripped = text.strip().lower()
    if NO_OUTPUT.lower() in stripped:
        return True
    no_response_patterns = [
        r"^$",
        r"^\s*$",
        r"^(no_output|no output|silent|мовчу|—)$",
        r"^\.{1,3}$",
    ]
    for pattern in no_response_patterns:
        if re.match(pattern, stripped, re.IGNORECASE):
            return True
    return False
