"""Local intent routing heuristics for Avatar chat (Phase B + 0.1.24)."""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Literal

from iuve_connect_agents.shared.feature_flags import (
    avatar_compound_intent_defer,
    avatar_execution_plan_enabled,
)
from iuve_connect_agents.shared.url_normalizer import extract_normalized_url_from_text

AvatarRequestType = Literal["device_task", "capabilities_faq", "meta_external"]

_URL_RE = re.compile(r"https?://[^\s<>\"']+", re.IGNORECASE)
_DOMAIN_RE = re.compile(
    r"\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}\b"
)

_SITE_TOKENS = (
    "сайт",
    "site",
    "url",
    "браузер",
    "browser",
    "перейди",
    "перейти",
    "open site",
    "open url",
    "открой сайт",
)
_STUDY_TOKENS = (
    "изуч",
    "исслед",
    "research",
    "read",
    "прочит",
    "содержим",
    "content",
    "узнай",
    "learn about",
)
_DOCUMENT_TOKENS = (
    "документ",
    "document",
    "doc",
    "word",
    "ворд",
    "libreoffice",
    "writer",
    "напиш",
    "write",
    "summary",
    "резюме",
    "кратко",
    "открой документ",
)
_STATUS_TOKENS = ("status", "статус", "состояние", "agent", "агент")
_APP_TOKENS = (
    "приложен",
    "программ",
    "application",
    "app ",
    "запусти",
    "launch",
    "открой программу",
)
_FILE_TOKENS = (
    "файл",
    "file",
    "папк",
    "folder",
    "workspace",
    "сохран",
    "save",
)
_SEARCH_TOKENS = (
    "найди",
    "поиск",
    "search",
    "find",
    "ищи",
)
_MULTI_ACTION_CONNECTORS = (
    " и ",
    ", ",
    " потом ",
    " затем ",
    " after ",
    " then ",
    " сначала ",
)
_NUMBERED_COMMAND_SPLIT_RE = re.compile(r"\d+[\.\)]\s*")
_MULTI_SEGMENT_SPLIT_RE = re.compile(
    r"\.\s+(?=(?:открой|запусти|open|launch|start)\s)",
    re.IGNORECASE,
)
_DOCUMENT_CONTEXT_TOKENS = (
    "документ",
    "document",
    "word",
    "ворд",
    "libreoffice",
    "writer",
    "в документе",
    "в документ",
)
_WRITE_TOKENS = (
    "напиш",
    "write",
    "стать",
    "article",
)
_FAQ_TOKENS = (
    "какие функции",
    "что умеешь",
    "что можешь",
    "what functions",
    "what can you",
    "what functions available",
    "какие возможности",
    "что ты умеешь",
    "чем можешь помочь",
    "что доступно",
    "what are your capabilities",
)
_META_TOKENS = (
    "task for cursor",
    "задача для cursor",
    "для cursor",
    "for cursor",
    "в cursor",
    "cursor ide",
    "cursor agent",
    "облак",
    "cloud",
    "backend",
    "разработк",
    "development",
    "код в репо",
    "code repo",
    "github",
    "gitlab",
)
_META_QUESTION_TOKENS = (
    "можешь",
    "можете",
    "умеешь",
    "can you",
    "could you",
    "are you able",
    "ты можешь",
)
_CURSOR_TYPING_TOKENS = (
    "напиш",
    "введ",
    "набери",
    "type",
    "задай",
    "спроси",
    "question",
    "вопрос",
    "произвольн",
    "chat with",
    "напиши в cursor",
    "дай команду",
    "сделай анализ",
    "проанализируй",
)
_COMMAND_TEXT_TRIGGERS = (
    "составь задание",
    "подробное задание",
    "напиши задание",
    "задай вопрос",
    "дай команду",
    "сделай анализ",
    "проанализируй",
    "и напиши",
    "и напиш",
    "напиши",
    "набери",
    "спроси",
    "напиш",
    "введ",
    "задай",
)
_CURSOR_COMMAND_TRIGGERS = _COMMAND_TEXT_TRIGGERS
_BROWSER_COMMAND_TRIGGERS = _COMMAND_TEXT_TRIGGERS
_CURSOR_IDE_AUTOMATION_TOKENS = _CURSOR_TYPING_TOKENS
_DEFAULT_CURSOR_QUESTION = "Проверь текущий проект и предложи следующие шаги"
_BROWSER_TYPING_TOKENS = _CURSOR_TYPING_TOKENS
_DEFAULT_BROWSER_QUESTION = "Расскажи кратко о своих возможностях и ограничениях"
_BROWSER_OPEN_VERBS = ("открой", "open ", "перейди", "go to", "зайди", "открыть")
_PUBLISH_TOKENS = (
    "опублик",
    "publish",
    "linkedin",
    "linked in",
    "social post",
    "пост в",
)
_MAINTENANCE_TOKENS = (
    "корзин",
    "trash",
    "recycle bin",
    "recycle",
    "очист",
    "empty trash",
)
_MEMORY_TOKENS = (
    "memory",
    "ram",
    "памят",
    "оператив",
)
_VISION_TOKENS = (
    "снимок",
    "screenshot",
    "screen capture",
    "capture screen",
    "скриншот",
    "снимок экрана",
    "сделай скриншот",
    "проверь экран",
    "проверить экран",
    "verify screen",
    "check screen",
    "видишь экран",
    "see the screen",
    "на экране",
    "on screen",
    "что изображено",
    "что на экране",
    "что сейчас на экране",
)
_DEVICE_ACTION_VERBS = (
    "создай",
    "создать",
    "открой",
    "открыть",
    "удали",
    "удалить",
    "запусти",
    "запустить",
    "сохрани",
    "сохранить",
    "скопируй",
    "скопировать",
    "перемести",
    "переместить",
    "найди",
    "найти",
    "покажи",
    "показать",
    "запиши",
    "записать",
    "напиши",
    "написать",
    "сделай",
    "сделать",
    "очисти",
    "очисть",
    "очистить",
    "empty ",
    "clear ",
    "create ",
    "open ",
    "delete ",
    "launch ",
    "save ",
    "copy ",
    "move ",
    "find ",
    "show ",
    "write ",
)
_PLAN_ONLY_TOKENS = (
    "только покажи план",
    "только покажи шаги",
    "пока ничего не",
    "пока ничего не делай",
    "ничего не копируй",
    "ничего не делай",
    "только покажи",
    "без выполнения",
    "без исполнения",
    "не выполняй",
    "only show plan",
    "only show steps",
    "do not copy",
    "don't copy",
    "plan only",
    "without executing",
    "without execution",
    "prepare plan",
    "no execution",
)
_PLAN_ONLY_VERB_RE = re.compile(
    r"\b(?:подготов\w*|состав\w*|prepare|draft)\s+(?:план|plan)\b",
    re.IGNORECASE,
)
_ROUTING_MODE_RE = re.compile(
    r"\b(?:в|in|mode)\s+(safe|balanced|trusted|сейф|баланс\w*|траст\w*)\b",
    re.IGNORECASE,
)
_BROWSER_SEARCH_NAV_RE = re.compile(
    r"\b(?:откр\w*|open)\s+(?:google|гугл)\b",
    re.IGNORECASE,
)
_BROWSER_FIND_OFFICIAL_RE = re.compile(
    r"\b(?:найд\w*|find|search)\b.{0,40}\b(?:openai|официальн\w*\s+сайт|official\s+site)\b",
    re.IGNORECASE,
)
_JOB_SEARCH_CITY_RE = re.compile(
    r"\b(?:мюнхен|münchen|munich|берлин|berlin|гамбург|hamburg|франкфурт|frankfurt)\b",
    re.IGNORECASE,
)
_JOB_AD_HINT_RE = re.compile(
    r"\b(?:объявлен\w*|ваканс\w*|job\s+ad|jobs?|работ\w*|driver|водител\w*)\b",
    re.IGNORECASE,
)
_LOCAL_COMPUTER_HINT_RE = re.compile(
    r"\b(?:поиск\w*\s+на\s+компьютер\w*|search\s+on\s+(?:the\s+)?computer|на\s+компьютер\w*)\b",
    re.IGNORECASE,
)
_ACTIVE_WINDOW_TOKENS = (
    "активн",
    "active window",
    "текущее окно",
    "какое окно",
    "which window",
)
_ACTIVE_WINDOW_DESCRIBE_TOKENS = (
    "опиш",
    "определ",
    "describe",
    "содержим",
    "content",
    "что на экране",
    "что сейчас",
)
_BROWSER_LOAD_TOKENS = (
    "полностью загруз",
    "fully load",
    "when loaded",
    "когда загруз",
    "сообщи, когда",
    "сообщи когда",
    "report when",
)
_SYSTEM_METRICS_TOKENS = (
    "процессор",
    "cpu",
    "загрузк процессор",
    "оператив",
    "ram",
    "памят",
    "диск",
    "disk",
    "свободн",
    "место на диске",
)
_APP_GOAL_SPLIT_RE = re.compile(
    r"\s+(?:и\s+)?(?:покаж\w*|показ\w*|show|display|вывед\w*|list|перечисл\w*|отсортируй|sort)\b",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class CompoundIntentResult:
    is_compound: bool
    defer_regex: bool
    categories_matched: tuple[str, ...]


def normalize_cursor_aliases(text: str) -> str:
    """Normalize common Cursor IDE typos (e.g. Cursot, окрой) for routing."""
    out = str(text or "")
    out = re.sub(r"\bcursot\b", "cursor", out, flags=re.IGNORECASE)
    out = re.sub(r"\bокрой\b", "открой", out, flags=re.IGNORECASE)
    return out


def has_cursor_reference(text: str) -> bool:
    lower = str(text or "").strip().lower()
    return "cursor" in lower or "cursot" in lower


def is_cursor_keyboard_type_intent(text: str) -> bool:
    """Type text into Cursor IDE chat — not LibreOffice document editing."""
    if not has_cursor_reference(text):
        return False
    lower = normalize_cursor_aliases(text).strip().lower()
    if is_meta_capability_question(text):
        return False
    if any(token in lower for token in _META_TOKENS):
        return False
    if not any(token in lower for token in _CURSOR_TYPING_TOKENS):
        return False
    if any(token in lower for token in ("документ", "word", "ворд", "libreoffice", "writer")):
        return False
    return True


def wants_cursor_type_follow_up(text: str) -> bool:
    """User wants Avatar to type into Cursor IDE after launch or in the open app."""
    return is_cursor_keyboard_type_intent(text)


def wants_cursor_launch_and_type(text: str) -> bool:
    """Open Cursor locally, then type a question into IDE chat."""
    return wants_local_cursor_launch(text) and wants_cursor_type_follow_up(text)


def _has_browser_site_reference(text: str) -> bool:
    lower = str(text or "").strip().lower()
    if any(noun in lower for noun in ("браузер", "browser")):
        if not any(verb in lower for verb in _APP_LAUNCH_VERBS):
            if any(
                marker in lower
                for marker in (
                    "сколько",
                    "how many",
                    "перечисл",
                    "list",
                    "назови",
                    "show",
                    "количество",
                )
            ):
                return False
    if extract_normalized_url_from_text(text):
        return True
    if any(token in lower for token in ("сайт", "site", "url", "браузер", "browser")):
        return True
    if "chatgpt" in lower:
        return True
    return False


def resolve_browser_url_from_text(text: str, *, memory_site: str = "") -> str:
    """Resolve target URL from NL; platform aliases and prompt-first priority."""
    from ..active.website_resolver import extract_target_site

    site = extract_target_site(text, memory_site=memory_site)
    if site:
        return site.url
    lower = str(text or "").strip().lower()
    if "chatgpt" in lower:
        return "https://chatgpt.com/"
    return "https://chatgpt.com/"


def is_browser_keyboard_type_intent(text: str) -> bool:
    """Type text into a browser chat input after opening a site."""
    if has_cursor_reference(text):
        return False
    lower = str(text or "").strip().lower()
    if not _has_browser_site_reference(text):
        return False
    if not any(token in lower for token in _BROWSER_TYPING_TOKENS):
        return False
    if any(token in lower for token in ("документ", "word", "ворд", "libreoffice", "writer")):
        return False
    return True


def wants_browser_open_and_type(text: str) -> bool:
    """Open a website in the browser, then type a question into the page chat input."""
    if not is_browser_keyboard_type_intent(text):
        return False
    lower = str(text or "").strip().lower()
    return any(verb in lower for verb in _BROWSER_OPEN_VERBS)


_BROWSER_SUBMIT_OPT_OUT_TOKENS = (
    "но не отправляй",
    "не отправляй",
    "без отправ",
    "не жми отправ",
    "don't send",
    "do not send",
    "without sending",
    "without submit",
    "no send",
)
_BROWSER_SUBMIT_OPT_IN_TOKENS = (
    "напиши и отправь",
    "и отправь",
    "отправь вопрос",
    "отправь сообщение",
    "press send",
    "send it",
    "type and send",
    "submit message",
)


def wants_screen_verification(text: str) -> bool:
    """True when the user asks to verify or capture the desktop screen."""
    from ..active.content_creation_intent import is_document_generation_intent

    if is_document_generation_intent(text):
        return False
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    return any(token in lower for token in _VISION_TOKENS)


_VISION_VERIFY_SUFFIX_RE = re.compile(
    r"\s*(?:,?\s*)?(?:и\s+)?(?:"
    r"проверь|убедись|confirm|verify|check|make sure"
    r")\b[\s\S]*$",
    re.IGNORECASE,
)
_VISION_QUOTED_TEXT_RE = re.compile(
    r"[«“\"']([^»”\"']+)[»”\"']",
)


def _strip_vision_verification_clause(text: str) -> str:
    """Remove trailing «и проверь, что…» / «and verify that…» from typed or expected text."""
    cleaned = str(text or "").strip()
    if not cleaned:
        return ""
    return _VISION_VERIFY_SUFFIX_RE.sub("", cleaned).strip(" ,.;—–-")


def _extract_quoted_phrase(text: str) -> str:
    match = _VISION_QUOTED_TEXT_RE.search(str(text or ""))
    if not match:
        return ""
    return str(match.group(1) or "").strip()


def extract_vision_verify_expected_text(
    *,
    request_text: str = "",
    typed_text: str = "",
    explicit_expected: str = "",
) -> str:
    """Derive bounded expected text for verify_screen_contains_text — never the full user prompt."""
    for candidate in (explicit_expected, typed_text, request_text):
        quoted = _extract_quoted_phrase(candidate)
        if quoted:
            return quoted

    for candidate in (explicit_expected, typed_text):
        stripped = _strip_vision_verification_clause(candidate)
        if stripped and len(stripped) <= 240:
            return stripped

    request = str(request_text or "").strip()
    if request:
        lower = request.lower()
        after_trigger = _extract_text_after_command_trigger(request, lower, _BROWSER_COMMAND_TRIGGERS)
        if after_trigger:
            quoted = _extract_quoted_phrase(after_trigger)
            if quoted:
                return quoted
            stripped = _strip_vision_verification_clause(after_trigger)
            if stripped and len(stripped) <= 240:
                return stripped

    return ""


def should_submit_browser_chat_message(text: str) -> bool:
    """Whether browser chat input should be submitted after typing (owner trust default: send)."""
    lower = str(text or "").strip().lower()
    if not lower:
        return True
    if any(token in lower for token in _BROWSER_SUBMIT_OPT_OUT_TOKENS):
        return False
    if any(token in lower for token in _BROWSER_SUBMIT_OPT_IN_TOKENS):
        return True
    if "задай вопрос" in lower or ("задай" in lower and "вопрос" in lower):
        return True
    if wants_browser_open_and_type(text) or is_chatgpt_browser_intent(text):
        return True
    return True


def _extract_text_after_command_trigger(
    raw: str,
    lower: str,
    triggers: tuple[str, ...] = _COMMAND_TEXT_TRIGGERS,
) -> str | None:
    """Pull command/question text after a known typing trigger phrase."""
    for trigger in sorted(triggers, key=len, reverse=True):
        idx = lower.find(trigger)
        if idx < 0:
            continue
        tail = raw[idx + len(trigger) :].strip()
        if tail.startswith((":", "—", "–", "-")):
            tail = tail[1:].strip()
        if tail and len(tail) > 2:
            return tail
    return None


def _requests_arbitrary_browser_question(tail: str) -> bool:
    """True when the user asked for any/random question, not a concrete typed task."""
    lower = str(tail or "").strip().lower()
    if not lower:
        return False
    if "любой вопрос" in lower:
        return True
    if re.search(r"\bпроизвольн\w*\s+вопрос\b", lower):
        return True
    if re.fullmatch(r"произвольн\w*", lower):
        return True
    return False


def sanitize_browser_keyboard_text(typed_text: str, *, source_request: str = "") -> str:
    """Ensure only the user task is typed — never DEFAULT prefix concatenated with a real task."""
    text = str(typed_text or "").strip()
    if not text:
        return text
    default = _DEFAULT_BROWSER_QUESTION
    if text.startswith(default):
        remainder = text[len(default) :].strip()
        if remainder and len(remainder) > 2:
            return remainder
    if default in text and source_request:
        extracted = extract_browser_question_text(source_request)
        if extracted and extracted != default and default not in extracted:
            return extracted
    return text


def extract_browser_question_text(text: str) -> str:
    """Pull question text from NL (after colon or trigger phrase) or use a sensible default."""
    raw = str(text or "").strip()
    for separator in (":", "—", "–"):
        if separator in raw:
            tail = raw.split(separator, 1)[1].strip()
            if tail and len(tail) > 2:
                return sanitize_browser_keyboard_text(tail, source_request=raw)
    lower = raw.lower()
    after_trigger = _extract_text_after_command_trigger(raw, lower, _BROWSER_COMMAND_TRIGGERS)
    if after_trigger:
        if _requests_arbitrary_browser_question(after_trigger):
            return _DEFAULT_BROWSER_QUESTION
        return sanitize_browser_keyboard_text(after_trigger, source_request=raw)
    if _requests_arbitrary_browser_question(lower):
        return _DEFAULT_BROWSER_QUESTION
    if "задай вопрос" in lower or ("задай" in lower and "вопрос" in lower):
        return "Какие задачи ты можешь помочь решить сегодня?"
    return _DEFAULT_BROWSER_QUESTION


def _extract_text_after_cursor_command_trigger(raw: str, lower: str) -> str | None:
    """Pull command/question text after a known Cursor typing trigger phrase."""
    return _extract_text_after_command_trigger(raw, lower, _CURSOR_COMMAND_TRIGGERS)


def extract_cursor_question_text(text: str) -> str:
    """Pull question/command text from NL (after colon or trigger phrase) or use a default."""
    raw = str(text or "").strip()
    for separator in (":", "—", "–"):
        if separator in raw:
            tail = raw.split(separator, 1)[1].strip()
            if tail and len(tail) > 2:
                return tail
    lower = normalize_cursor_aliases(raw).lower()
    after_trigger = _extract_text_after_cursor_command_trigger(raw, lower)
    if after_trigger:
        return after_trigger
    if "любой вопрос" in lower or "произвольн" in lower:
        return "Опиши текущий проект и предложи следующие шаги"
    if "задай вопрос" in lower or ("задай" in lower and "вопрос" in lower):
        return "Проверь код в открытом проекте и предложи улучшения"
    if "дай команду" in lower:
        return "Выполни анализ текущего проекта и предложи следующие шаги"
    if "сделай анализ" in lower or "проанализируй" in lower:
        return "Проанализируй текущий проект и предложи улучшения"
    return _DEFAULT_CURSOR_QUESTION


def is_chatgpt_browser_intent(text: str) -> bool:
    """Open ChatGPT in the browser — must never route to Cursor IDE or LibreOffice."""
    lower = str(text or "").strip().lower()
    return "chatgpt" in lower or "chat.openai" in lower


def has_document_write_intent(text: str) -> bool:
    """True when user wants to write in a document — not browser chat or site research."""
    from ..active.libreoffice_document_intent import (
        is_document_article_failure_follow_up,
        is_libreoffice_article_intent,
        normalize_libreoffice_text,
    )

    if is_libreoffice_article_intent(text) or is_document_article_failure_follow_up(text):
        return True
    if wants_browser_open_and_type(text) or is_chatgpt_browser_intent(text):
        return False
    if is_research_write_compound_intent(text):
        return False
    normalized = normalize_libreoffice_text(text).strip().lower()
    has_document_context = any(token in normalized for token in _DOCUMENT_CONTEXT_TOKENS)
    has_write = any(token in normalized for token in _WRITE_TOKENS)
    return has_document_context and has_write


def is_research_write_compound_intent(text: str) -> bool:
    """Site research then write to document (stauceni / iuve.eu flow)."""
    if wants_browser_open_and_type(text) or is_chatgpt_browser_intent(text):
        return False
    lower = str(text or "").strip().lower()
    has_site = bool(_URL_RE.search(text)) or bool(_DOMAIN_RE.search(text)) or any(
        token in lower for token in _SITE_TOKENS
    )
    has_study = any(token in lower for token in _STUDY_TOKENS)
    has_document = any(token in lower for token in _DOCUMENT_CONTEXT_TOKENS) or any(
        token in lower for token in _WRITE_TOKENS
    )
    return has_site and has_study and has_document


def split_multi_command_segments(text: str) -> list[str]:
    """Split numbered or chained imperative lines into individual launch commands."""
    raw = str(text or "").strip()
    if not raw:
        return []

    segments: list[str] = []
    for chunk in _NUMBERED_COMMAND_SPLIT_RE.split(raw):
        chunk = str(chunk or "").strip().strip(".").strip()
        if not chunk:
            continue
        for piece in _MULTI_SEGMENT_SPLIT_RE.split(chunk):
            piece = str(piece or "").strip().strip(".").strip()
            if not piece:
                continue
            lower = piece.lower()
            if any(verb in lower for verb in _APP_LAUNCH_VERBS):
                segments.append(piece)
    return segments


def _is_actionable_launch_segment(segment: str) -> bool:
    """True when segment names a concrete app to launch (not a generic header line)."""
    if wants_local_cursor_launch(segment):
        return True
    if not is_generic_app_launch_intent(segment):
        return False
    query = (extract_app_query_from_text(segment) or "").strip().lower()
    if not query or len(query) < 2:
        return False
    blocked = frozenset(
        {
            "программы",
            "программа",
            "приложения",
            "приложение",
            "programs",
            "applications",
            "program",
            "application",
        }
    )
    return query not in blocked


def is_multi_app_launch_command(text: str) -> bool:
    """True when text contains two or more distinct app-launch imperatives."""
    segments = split_multi_command_segments(text)
    if len(segments) < 2:
        return False
    launchable = sum(1 for segment in segments if _is_actionable_launch_segment(segment))
    return launchable >= 2


def wants_generic_app_launch_and_type(text: str) -> bool:
    """Launch a local desktop app, then type text (compound, non-Cursor)."""
    if wants_cursor_launch_and_type(text):
        return False
    if wants_browser_open_and_type(text):
        return False
    if not is_generic_app_launch_intent(text):
        return False
    lower = str(text or "").strip().lower()
    return any(token in lower for token in _COMMAND_TEXT_TRIGGERS)


def extract_app_type_text(text: str) -> str:
    """Pull text to type after launching a generic desktop app."""
    raw = str(text or "").strip()
    lower = raw.lower()
    after_trigger = _extract_text_after_command_trigger(raw, lower, _COMMAND_TEXT_TRIGGERS)
    if after_trigger and len(after_trigger) > 2:
        return after_trigger
    for separator in (":", "—", "–"):
        if separator in raw:
            tail = raw.split(separator, 1)[1].strip()
            if tail and len(tail) > 2:
                return tail
    if "вопрос" in lower:
        return "Расскажи кратко о своих возможностях"
    return "Привет"


def wants_local_cursor_launch(text: str) -> bool:
    """Imperative to open Cursor IDE locally — device_task, not meta_external."""
    if is_chatgpt_browser_intent(text):
        return False
    if wants_browser_open_and_type(text):
        return False
    if not has_cursor_reference(text):
        return False
    lower = normalize_cursor_aliases(text).strip().lower()
    if any(token in lower for token in _META_TOKENS):
        return False
    launch_verbs = ("открой", "open ", "запусти", "launch", "start ")
    if not any(verb in lower for verb in launch_verbs):
        return False
    if any(token in lower for token in _APP_TOKENS):
        return True
    if "открой cursor" in lower or "open cursor" in lower:
        return True
    if "запусти cursor" in lower or "launch cursor" in lower:
        return True
    return False


def is_meta_capability_question(text: str) -> bool:
    """Questions about Cursor/browser boundaries — not executable device commands."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if wants_local_cursor_launch(text):
        return False
    is_question = "?" in text or any(token in lower for token in _META_QUESTION_TOKENS)
    if not is_question:
        return False
    has_cursor = "cursor" in lower
    mentions_browser = any(token in lower for token in ("браузер", "browser", "сайт", "site"))
    if has_cursor and (mentions_browser or "задать вопрос" in lower or "ask" in lower):
        return True
    if has_cursor and is_question:
        return True
    return False


def is_cursor_ide_boundary_request(text: str) -> bool:
    """Reserved for cloud-only Cursor automation — local app launch is device_task."""
    return False


def cursor_ide_chat_manual_follow_up(text: str) -> bool:
    """Legacy alias — typing into Cursor is automated when keyboard_control is granted."""
    return wants_cursor_type_follow_up(text)


_PLAN_RESUME_TOKENS = (
    "продолжай",
    "continue",
    "возобнови",
    "повтори шаг",
    "resume plan",
    "resume",
)


def is_plan_resume_intent(text: str) -> bool:
    """Detect user intent to resume a paused multi-step plan."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    normalized = lower.rstrip(".!?")
    for token in _PLAN_RESUME_TOKENS:
        if normalized == token or normalized.startswith(f"{token} "):
            return True
    return False


def is_explicit_device_action_intent(text: str) -> bool:
    """True when NL contains imperative device verbs (RU/EN), not advisory-only."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if any(token in lower for token in _PLAN_ONLY_TOKENS):
        return False
    if classify_avatar_request_type(text) in {"capabilities_faq", "meta_external"}:
        return False
    if extract_normalized_url_from_text(text) and any(token in lower for token in _STUDY_TOKENS):
        return False
    has_verb = any(verb in lower for verb in _DEVICE_ACTION_VERBS)
    if not has_verb:
        return False
    if re.search(r"\bapp\b", lower) and not any(
        token in lower for token in ("приложен", "программ", "application", "запусти", "launch", "открой")
    ):
        return False
    has_target = any(
        token in lower
        for token in (
            *_FILE_TOKENS,
            *_APP_TOKENS,
            *_SITE_TOKENS,
            "браузер",
            "browser",
            "cursor",
            "документ",
            "document",
            "корзин",
            "trash",
            "recycle",
            "мусор",
            "агент",
            "agent",
            "устройств",
            "device",
            "экран",
            "screen",
            "desktop",
            "рабоч",
        )
    )
    return has_target or is_workspace_file_creation_intent(text)


def is_workspace_file_creation_intent(text: str) -> bool:
    """Create folder/files in workspace — device action, not consultation."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if any(token in lower for token in _PLAN_ONLY_TOKENS):
        return False
    if re.search(r"\bзатем\s+команд\w*\b", lower):
        return True
    if wants_browser_open_and_type(text) or is_chatgpt_browser_intent(text):
        return False
    if is_research_write_compound_intent(text):
        return False
    has_create = any(
        token in lower
        for token in (
            "создай",
            "создать",
            "create ",
            "make ",
            "сделай папк",
            "сделай файл",
        )
    )
    has_file_target = any(token in lower for token in _FILE_TOKENS) or "файл" in lower
    has_write = any(token in lower for token in ("запиш", "write", "сохран", "save"))
    if has_create and has_file_target:
        return True
    if has_create and has_write and ("файл" in lower or "file" in lower):
        return True
    return False


def classify_avatar_request_type(text: str) -> AvatarRequestType:
    """Classify NL input: device work, capabilities FAQ, or meta/external tooling."""
    from ..active.libreoffice_document_intent import should_block_meta_external_for_local_document

    lower = str(text or "").strip().lower()
    if not lower:
        return "device_task"

    if should_block_meta_external_for_local_document(text):
        return "device_task"

    if any(token in lower for token in _FAQ_TOKENS):
        return "capabilities_faq"

    if is_meta_capability_question(text):
        return "meta_external"

    if any(token in lower for token in _META_TOKENS):
        return "meta_external"

    return "device_task"


def detect_compound_intent(text: str) -> CompoundIntentResult:
    """Detect multi-step goals vs single commands (broadened beyond stauceni pattern)."""
    lower = str(text or "").strip().lower()
    if not lower:
        return CompoundIntentResult(is_compound=False, defer_regex=False, categories_matched=())

    if is_plan_only_intent(text):
        return CompoundIntentResult(is_compound=False, defer_regex=False, categories_matched=("plan_only",))

    if any(token in lower for token in _STATUS_TOKENS) and (
        "agent" in lower or "агент" in lower or "устройств" in lower
    ):
        return CompoundIntentResult(is_compound=False, defer_regex=False, categories_matched=("status",))

    categories: list[str] = []
    has_site = bool(_URL_RE.search(text)) or bool(_DOMAIN_RE.search(text)) or any(
        token in lower for token in _SITE_TOKENS
    )
    has_study = any(token in lower for token in _STUDY_TOKENS)
    has_publish = any(token in lower for token in _PUBLISH_TOKENS)
    has_document = has_document_write_intent(text) or any(
        token in lower for token in _DOCUMENT_CONTEXT_TOKENS
    )
    if not has_document and any(token in lower for token in ("напиш", "write", "пост", "post")):
        if has_publish:
            has_document = True
    has_app = any(token in lower for token in _APP_TOKENS)
    has_file = any(token in lower for token in _FILE_TOKENS)
    has_search = any(token in lower for token in _SEARCH_TOKENS)
    has_maintenance = any(token in lower for token in _MAINTENANCE_TOKENS)
    has_memory = any(token in lower for token in _MEMORY_TOKENS)
    has_vision = wants_screen_verification(text)
    has_multi_connector = any(token in lower for token in _MULTI_ACTION_CONNECTORS)

    if has_site:
        categories.append("site")
    if has_study:
        categories.append("study")
    if has_document:
        categories.append("document")
    if has_app:
        categories.append("app")
    if has_file:
        categories.append("file")
    if has_search:
        categories.append("search")
    if has_publish:
        categories.append("publish")
    if has_maintenance:
        categories.append("maintenance")
    if has_memory:
        categories.append("memory")
    if has_vision:
        categories.append("vision")

    # Site + study (even without document) is a multi-step research goal.
    is_compound = has_site and has_study
    if has_site and has_document:
        is_compound = True
    if has_site and has_file:
        is_compound = True
    if len(categories) >= 3:
        is_compound = True
    if has_multi_connector and has_site and len(categories) >= 2:
        is_compound = True
    if has_multi_connector and has_study and (has_document or has_file):
        is_compound = True
    if has_multi_connector and has_file and has_document:
        is_compound = True
    if has_document and has_publish:
        is_compound = True
    if has_multi_connector and has_publish and (has_document or has_site):
        is_compound = True
    if has_memory and has_maintenance:
        is_compound = True
    if has_multi_connector and has_memory and has_maintenance:
        is_compound = True
    if wants_browser_open_and_type(text):
        is_compound = True
        if "browser_type" not in categories:
            categories.append("browser_type")

    if is_workspace_file_creation_intent(text):
        is_compound = True
        if "file" not in categories:
            categories.append("file")

    defer = is_compound and avatar_execution_plan_enabled() and avatar_compound_intent_defer()
    if wants_browser_open_and_type(text) or wants_cursor_launch_and_type(text):
        defer = False
    if is_workspace_file_creation_intent(text):
        defer = False
    return CompoundIntentResult(
        is_compound=is_compound,
        defer_regex=defer,
        categories_matched=tuple(categories),
    )


def should_defer_to_planner(text: str) -> bool:
    return detect_compound_intent(text).defer_regex


_APP_LAUNCH_VERBS = (
    "открой",
    "open ",
    "запусти",
    "launch",
    "start ",
    "открыть",
    "запуск",
)
_APP_QUERY_PATTERNS = (
    re.compile(r"открой\s+программу\s+(.+)", re.IGNORECASE),
    re.compile(r"запусти\s+программу\s+(.+)", re.IGNORECASE),
    re.compile(r"open\s+(?:the\s+)?(?:program|application)\s+(.+)", re.IGNORECASE),
    re.compile(r"launch\s+(?:the\s+)?(?:program|application)?\s*(.+)", re.IGNORECASE),
    re.compile(r"открой\s+(.+)", re.IGNORECASE),
    re.compile(r"запусти\s+(.+)", re.IGNORECASE),
    re.compile(r"open\s+(.+)", re.IGNORECASE),
    re.compile(r"launch\s+(.+)", re.IGNORECASE),
    re.compile(r"start\s+(.+)", re.IGNORECASE),
)
_APP_QUERY_STOPWORDS = frozenset(
    {
        "программу",
        "программа",
        "приложение",
        "приложения",
        "application",
        "program",
        "the",
        "please",
        "пожалуйста",
        "мне",
        "на",
        "компьютере",
        "устройстве",
        "desktop",
        "local",
        "локально",
    }
)


def is_generic_app_launch_intent(text: str) -> bool:
    """True when user asks to open/launch a local desktop app (not browser/site/document)."""
    if wants_local_cursor_launch(text):
        return False
    if is_chatgpt_browser_intent(text):
        return False
    if wants_browser_open_and_type(text):
        return False
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if not any(verb in lower for verb in _APP_LAUNCH_VERBS):
        return False
    if extract_normalized_url_from_text(text):
        return False
    if any(token in lower for token in _SITE_TOKENS):
        return False
    if any(token in lower for token in _FILE_TOKENS) and not any(
        token in lower for token in _APP_TOKENS
    ):
        return False
    if any(token in lower for token in ("документ", "document", "word", "ворд", "writer")) and not any(
        token in lower for token in ("libreoffice", "onlyoffice", "writer")
    ):
        return False
    if extract_app_query_from_text(text):
        return True
    return any(token in lower for token in _APP_TOKENS)


def extract_app_post_launch_goal(text: str) -> str:
    """Text after app launch describing follow-up goal (e.g. show top 5 CPU processes)."""
    raw = str(text or "").strip()
    if not raw:
        return ""
    split = _APP_GOAL_SPLIT_RE.split(raw, maxsplit=1)
    if len(split) < 2:
        return ""
    return str(split[1] or "").strip(" .,:;—–-")


def extract_app_query_from_text(text: str) -> str | None:
    """Extract target application name from NL launch phrases."""
    raw = str(text or "").strip()
    if not raw:
        return None
    for pattern in _APP_QUERY_PATTERNS:
        match = pattern.search(raw)
        if not match:
            continue
        candidate = match.group(1).strip(" .,:;!?\"'«»")
        if not candidate:
            continue
        goal_split = _APP_GOAL_SPLIT_RE.split(candidate, maxsplit=1)
        candidate = str(goal_split[0] or "").strip(" .,:;!?\"'«»")
        tokens = [token for token in re.split(r"\s+", candidate.lower()) if token]
        while tokens and tokens[-1] in _APP_QUERY_STOPWORDS:
            tokens.pop()
        while tokens and tokens[0] in _APP_QUERY_STOPWORDS:
            tokens.pop(0)
        cleaned = " ".join(tokens).strip()
        if cleaned and len(cleaned) >= 2:
            return cleaned
    return None


def is_active_window_analysis_intent(text: str) -> bool:
    """Screen/window description — must route to Vision, not diagnostics or folder scan."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if re.search(r"\b(?:рабоч\w*\s+стол|desktop|папк|folder|директор|каталог)\b", lower):
        if re.search(r"\b(?:содержим|файл|files?|список)\b", lower):
            return False
        if re.search(r"\b(?:анализ\w*|обзор)\b", lower) and "экран" not in lower and "screen" not in lower:
            return False
    if re.search(r"\bчто\s+на\s+рабоч\w*\s+стол", lower):
        return False
    if wants_screen_verification(text) and any(token in lower for token in _ACTIVE_WINDOW_TOKENS):
        return True
    has_window = any(token in lower for token in _ACTIVE_WINDOW_TOKENS) or "окн" in lower
    has_describe = any(token in lower for token in _ACTIVE_WINDOW_DESCRIBE_TOKENS)
    if has_window and has_describe:
        if re.search(r"\b(?:рабоч\w*\s+стол|desktop)\b", lower) and "экран" not in lower:
            return False
        return True
    if "активн" in lower and "окн" in lower:
        return True
    if re.search(r"\bчто\s+(?:на|сейчас\s+на)\s+экран", lower):
        if not re.search(r"\b(?:рабоч\w*\s+стол|desktop)\b", lower):
            return True
    return False


def wants_browser_load_completion(text: str) -> bool:
    """Open URL and verify page fully loaded — opening alone is not completion."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if wants_browser_open_and_type(text):
        return False
    has_open = any(verb in lower for verb in _BROWSER_OPEN_VERBS) or any(
        token in lower for token in _SITE_TOKENS
    )
    if not has_open and not extract_normalized_url_from_text(text):
        if "iuve" not in lower and "официальн" not in lower:
            return False
    has_load_goal = any(token in lower for token in _BROWSER_LOAD_TOKENS)
    if has_load_goal:
        return True
    if ("iuve" in lower or "официальн" in lower) and ("сайт" in lower or "site" in lower):
        if any(token in lower for token in ("загруз", "load", "сообщ", "report", "когда")):
            return True
    return False


def is_plan_only_intent(text: str) -> bool:
    """Informational plan without execution (backup, migration, etc.)."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if any(token in lower for token in _PLAN_ONLY_TOKENS):
        return True
    if _PLAN_ONLY_VERB_RE.search(lower):
        return True
    if "план" in lower and any(token in lower for token in ("резерв", "backup", "копир", "миграц", "migration")):
        if any(
            token in lower
            for token in (
                "ничего не",
                "не копируй",
                "только покаж",
                "only show",
                "без выполн",
                "без исполн",
                "without execut",
            )
        ):
            return True
    if "backup" in lower and "план" in lower:
        return True
    return False


def extract_routing_mode_from_text(text: str) -> str | None:
    """Parse explicit Safe/Balanced/Trusted routing mode from NL (not folder name)."""
    raw = str(text or "")
    match = _ROUTING_MODE_RE.search(raw)
    if not match:
        return None
    token = str(match.group(1) or "").strip().lower()
    if token in {"safe", "сейф"}:
        return "safe"
    if token.startswith("balanc") or token.startswith("баланс"):
        return "balanced"
    if token.startswith("trust") or token.startswith("траст"):
        return "trusted"
    return None


def is_browser_search_navigation_intent(text: str) -> bool:
    """Open Google → search → navigate to official site (multi-step browser flow)."""
    raw = str(text or "").strip()
    if not raw:
        return False
    if has_cursor_reference(raw):
        return False
    if not _BROWSER_SEARCH_NAV_RE.search(raw):
        return False
    return bool(_BROWSER_FIND_OFFICIAL_RE.search(raw) or "openai" in raw.lower())


def is_job_search_intent(text: str) -> bool:
    """Job/vacancy web research — объявления, вакансии, driver category B, cities."""
    raw = str(text or "").strip()
    if not raw:
        return False
    if _LOCAL_COMPUTER_HINT_RE.search(raw):
        return False
    lower = raw.lower()
    has_job = bool(_JOB_AD_HINT_RE.search(raw)) or re.search(r"\bводител\w*\b", lower)
    has_search = any(token in lower for token in _SEARCH_TOKENS) or "найти" in lower
    if has_job and has_search:
        return True
    if has_job and (
        re.search(r"\bкатегор\w*\s+b\b", lower)
        or "category b" in lower
        or _JOB_SEARCH_CITY_RE.search(raw)
    ):
        return True
    if has_job and _JOB_SEARCH_CITY_RE.search(raw):
        return True
    return False


def is_full_system_metrics_query(text: str) -> bool:
    """CPU + RAM + disk table request."""
    lower = str(text or "").strip().lower()
    if not lower:
        return False
    if is_folder_analysis_intent(text):
        return False
    if is_active_window_analysis_intent(text):
        return False
    if detect_compound_intent(text).is_compound:
        return False
    has_cpu = any(token in lower for token in ("процессор", "cpu", "загрузк процессор"))
    has_ram = any(token in lower for token in ("памят", "ram", "оператив", "озу"))
    has_disk = any(token in lower for token in ("диск", "disk", "место на диске", "свободн"))
    has_table = "таблиц" in lower or "table" in lower
    metric_count = sum([has_cpu, has_ram, has_disk])
    if metric_count >= 2:
        return True
    if has_table and metric_count >= 1:
        return True
    return False


def is_downloads_file_search_intent(text: str) -> bool:
    """Find/list files in Downloads or other user folders (not workspace)."""
    from ..active.local_search_resolver import is_local_search_intent

    return is_local_search_intent(text)


def is_local_search_intent(text: str) -> bool:
    """LOCAL_SEARCH — find/list/sort files in XDG user dirs or explicit path."""
    from ..active.local_search_resolver import is_local_search_intent as _is_local_search

    return _is_local_search(text)


def is_folder_analysis_intent(text: str) -> bool:
    """FOLDER_ANALYSIS — analyze folder/desktop contents (not device diagnostics)."""
    from ..active.folder_analysis_resolver import is_folder_analysis_intent as _is_folder_analysis

    return _is_folder_analysis(text)


def is_web_research_intent(text: str) -> bool:
    """WEB_RESEARCH — open browser search for internet queries."""
    if is_job_search_intent(text):
        return True
    from ..active.web_research_resolver import is_web_research_intent as _is_web_research

    return _is_web_research(text)


def is_local_computer_job_search_intent(text: str) -> bool:
    """LOCAL_SEARCH keyword scan for job ads on this computer."""
    from ..active.web_research_resolver import is_local_computer_job_search_intent as _is_local

    return _is_local(text)
