import json
import re
from datetime import datetime, timedelta
from typing import Any, TypedDict

from backend.config import PLAYWRIGHT_SUPPORT
from backend.intent_frames import (
    build_intent_frame,
    intent_frame_display_goal,
    intent_frame_from_direct_intent,
    intent_frame_to_direct_intent,
)
from backend.task_understanding import (
    task_state_display_goal,
    task_state_to_direct_intent,
    _extract_explicit_date_range,
    _detect_time_filter,
)


DirectIntent = dict[str, Any]
ToolArgs = dict[str, Any]


class DirectToolCall(TypedDict):
    tool: str
    args: ToolArgs


class DirectAccelerationResult(TypedDict):
    goal: str
    display_goal: str
    intent: DirectIntent | None
    calls: list[DirectToolCall]


class ExtractSourceRequest(TypedDict):
    source: str
    max_chars: int
    display_goal: str


_SUMMARY_REQUEST_RE = re.compile(
    r"\b(?:samenvat(?:ten)?|vat\s+samen|samenvatting|summary|summari[sz]e|kort\s+samen|analyseer|analy[sz]e|leg\s+uit|explain)\b",
    re.IGNORECASE,
)

_EMAIL_SUMMARY_NOISE_RE = re.compile(
    r"\b(?:samenvat(?:ten|ting)?|summary|summari[sz]e|uitleg(?:gen)?|leg\s+uit|explain|analyseer|analy[sz]e)\b",
    re.IGNORECASE,
)
_EMAIL_QUERY_STOPWORDS = {
    "ik", "i", "me", "mij", "mijn", "my", "mine", "we", "wij",
    "de", "het", "een", "the", "a", "an",
    "mail", "email", "e-mail", "inbox", "postvak", "berichten", "messages",
    "zoek", "zoeken", "opzoeken", "search", "find", "lookup", "look",
    "in", "op", "via", "naar", "for", "about", "over", "van", "from",
    "agenda", "calendar",
    "weer", "paar", "week", "weken", "geleden", "later", "ago", "voor", "toen",
    "kun", "kunt", "kan", "jij", "je", "u", "please", "title", "titel",
    "unknown", "weet", "know", "dont", "don't",
}
# Negation words preserved separately so "no attachment" → "-attachment"
_EMAIL_QUERY_NEGATION_WORDS = {"no", "not", "without", "niet", "geen", "zonder"}
_EXTRACTABLE_TYPE_HINT_RE = re.compile(
    r"\b(?:url|link|pagina|website|site|pdf|document|youtube|youtu\.be|podcast|feed|rss|video|audio|afbeelding|image)\b",
    re.IGNORECASE,
)
_SOURCE_PATTERNS = [
    re.compile(r"https?://[^\s\"')]+", re.IGNORECASE),
    re.compile(r"(?<!@)\bwww\.[^\s\"')]+", re.IGNORECASE),
    re.compile(
        r"[a-zA-Z]:\\[^\n\r\"']+?\.(?:pdf|txt|md|html?|png|jpe?g|gif|webp|bmp|mp3|wav|m4a|aac|flac|ogg|mp4|mov|mkv|avi|webm|rss|xml)",
        re.IGNORECASE,
    ),
    re.compile(
        r"(?:\./|\.\\|/)?[^\s\"']+?\.(?:pdf|txt|md|html?|png|jpe?g|gif|webp|bmp|mp3|wav|m4a|aac|flac|ogg|mp4|mov|mkv|avi|webm|rss|xml)",
        re.IGNORECASE,
    ),
]

_WEB_DOMAIN_RE = re.compile(
    r"(?<!@)\b(?:https?://|www\.)[^\s\"')]+\b|(?<!@)\b[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.(?:nl|com|org|net|io|ai|app|dev|co|be|de|fr|uk|eu|info|biz|me|tv|edu|gov|ca|us|ch|it|es)(?:/[^\s\"')]+)?",
    re.IGNORECASE,
)
_BROWSER_SEARCH_RE = re.compile(
    r"\b(?:gebruik\s+(?:je|de)\s+browser|use\s+(?:the\s+)?browser|ga\s+naar|navigeer\s+naar|navigate\s+to|go\s+to|browse\s+to|open(?:en)?\s+(?:de\s+|het\s+)?(?:website|site|pagina|url)|browser|browse)\b",
    re.IGNORECASE,
)
_BROWSER_NAVIGATION_RE = re.compile(
    r"\b(?:gebruik\s+(?:je|de)\s+browser|use\s+(?:the\s+)?browser|ga\s+naar|navigeer\s+naar|navigate\s+to|go\s+to|browse\s+to|bezoek|visit|open(?:en)?\s+(?:(?:de\s+|het\s+)?(?:website|site|pagina|page|url)(?:\s+van)?\s+)?)",
    re.IGNORECASE,
)







def _gmail_before_value(until: str) -> str:
    if not until:
        return ""
    try:
        return (datetime.strptime(until, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
    except ValueError:
        return until


def _extract_explicit_source(text: str) -> str:
    raw = str(text or "").strip()
    if not raw:
        return ""

    quoted = re.findall(r'"([^\"]+)"|\'([^\']+)\'', raw)
    for left, right in quoted:
        candidate = (left or right or "").strip()
        if candidate:
            for pattern in _SOURCE_PATTERNS:
                match = pattern.search(candidate)
                if match:
                    return match.group(0).rstrip('.,;:!?)')

    for pattern in _SOURCE_PATTERNS:
        match = pattern.search(raw)
        if match:
            return match.group(0).rstrip('.,;:!?)')
    return ""


def detect_extract_source_request(text: str) -> ExtractSourceRequest | None:
    raw = str(text or "").strip()
    if not raw or _is_write_or_action_request(raw):
        return None

    source = _extract_explicit_source(raw)
    if not source:
        return None

    if not (_SUMMARY_REQUEST_RE.search(raw) or _EXTRACTABLE_TYPE_HINT_RE.search(raw)):
        return None

    return {
        "source": source,
        "max_chars": 24000,
        "display_goal": f"Extract and summarize source: {source}",
    }


def _is_write_or_action_request(text: str) -> bool:
    return bool(re.search(
        r"\b(?:"
        r"send|reply|forward|"
        r"delete|remove|"
        r"create|schedule|upload|write|post|draft|"
        r"update|edit|change|move|reschedule|"
        r"verstuur\w*|stuur\w*|antwoord\w*|doorstuur\w*|"
        r"verwijder\w*|maak\w*|inplan\w*|plan\w*|inboek\w*|boek\w*|"
        r"zet\w*|toevoeg\w*|voeg\s+toe|plaats\w*|"
        r"wijzig\w*|bewerk\w*|verplaats\w*|verzet\w*|"
        r"aanpas\w*|verschuif\w*|verander\w*|herplan\w*|vervroeg\w*|verlaat\w*"
        r")\b",
        str(text or ""),
        re.IGNORECASE,
    ))


def _match_folder_profile(query: str, profiles: dict) -> str | None:
    """Return the best-matching folder path from profiles, or None."""
    if not query or not profiles:
        return None
    best: str | None = None
    best_score = 0
    for folder_path, description in profiles.items():
        segments = folder_path.split("/")
        meaningful = [s for s in segments if s and s.lower() != "inbox"]
        if not meaningful:
            continue
        score = 0
        for seg in meaningful:
            # Match whole segment first, then individual words
            seg_lower = seg.lower()
            if re.search(r"\b" + re.escape(seg_lower) + r"\b", query):
                score += len(seg_lower) + len(segments)
            else:
                for word in seg_lower.split():
                    if len(word) >= 2 and re.search(r"\b" + re.escape(word) + r"\b", query):
                        score += len(word) + len(segments)
        if score == 0 and description:
            desc_lower = description.lower()
            for word in query.split():
                if len(word) >= 4 and word in desc_lower:
                    score += len(word)
        if score > best_score:
            best_score = score
            best = folder_path
    return best


def _detect_email_folder(text: str, folder_profiles: dict | None = None) -> str:
    value = (text or "").lower()
    _has_sent = bool(re.search(r"\b(sent|sent items|verzonden|verzonden items|verstuurd|gestuurd)\b", value))
    if _has_sent:
        # If the "sent" keyword only appears inside a conditional fallback clause
        # (e.g. "als je niets vindt, probeer ook in verzonden items"), the
        # primary intent is still Inbox.  Detect inbox-primary signals.
        _has_inbox_primary = bool(re.search(
            r"\b(ontvangen|received|gekregen|binnengekregen|inbox|postvak)\b", value
        ))
        _sent_in_conditional = bool(re.search(
            r"\b(?:als|if|anders|otherwise|indien|mocht)\b.{0,60}\b(?:verzonden|sent|verstuurd|gestuurd)\b",
            value,
        ))
        if _has_inbox_primary and _sent_in_conditional:
            pass  # fall through to Inbox default
        else:
            return "Sent"
    if re.search(r"\b(drafts|concepten)\b", value):
        return "Drafts"
    if re.search(r"\b(deleted|deleted items|trash|prullenbak)\b", value):
        return "Deleted"
    if folder_profiles:
        matched = _match_folder_profile(value, folder_profiles)
        if matched:
            return matched
    return "Inbox"


def _detect_email_source_scope(
    text: str,
    *,
    use_outlook: bool = False,
    gmail_available: bool = False,
) -> tuple[bool, bool, bool]:
    value = (text or "").lower()
    mentions_gmail = bool(re.search(r"\b(gmail|google mail|google agenda|google calendar)\b", value))
    mentions_outlook = bool(re.search(r"\b(outlook)\b", value))
    wants_cross_source = bool(re.search(
        r"\b(beide|both|allebei|all mailboxes|all inboxes|all accounts|across accounts|combine|merge|vergelijk|compare)\b",
        value,
    ))

    if wants_cross_source:
        return use_outlook, gmail_available, True
    if mentions_gmail and not mentions_outlook:
        return False, gmail_available, True
    if mentions_outlook and not mentions_gmail:
        return use_outlook, False, True
    if use_outlook and gmail_available:
        return True, True, False
    if use_outlook:
        return True, False, False
    if gmail_available:
        return False, True, False
    return False, False, False


def _step_mentions_provider(step_desc: str, provider: str) -> bool:
    value = (step_desc or "").lower()
    if provider == "gmail":
        return bool(re.search(r"\b(gmail|google mail|google agenda|google calendar)\b", value))
    if provider == "outlook":
        return bool(re.search(r"\b(outlook)\b", value))
    return False


def _step_is_cross_source(step_desc: str) -> bool:
    value = (step_desc or "").lower()
    return bool(re.search(r"\b(merge|combine|compare|vergelijk|both|beide|across accounts|all accounts)\b", value))


def _detect_outlook_mailbox_scope(text: str) -> str:
    value = (text or "").lower()
    if re.search(r"\b(beide|both|allebei|all mailboxes|all inboxes|all accounts|across accounts|combine|merge|vergelijk|compare)\b", value):
        return "ALL"
    return "DEFAULT"


def _detect_calendar_days_ahead(text: str) -> int:
    value = (text or "").lower()
    if re.search(r"\b(vandaag|today)\b", value):
        return 1
    if re.search(r"\b(morgen|tomorrow)\b", value):
        return 2
    if re.search(r"\b(deze week|this week)\b", value):
        return 7
    if re.search(r"\b(volgende week|next week)\b", value):
        return 14
    match = re.search(r"\b(?:next|komende)\s+(\d{1,2})\s+(?:days?|dagen)\b", value)
    if match:
        try:
            return max(1, min(int(match.group(1)), 31))
        except Exception:
            return 7
    return 7


def _extract_email_search_hint(text: str) -> str:
    raw = str(text or "").strip()
    value = raw.lower()
    if not raw:
        return ""

    # Dutch existence pattern: "of ik <topic> heb/heeft/hebben/gekregen/ontvangen"
    existence_match = re.search(
        r"\bof\s+(?:ik|we|wij|je|er)\s+(.+?)\s+(?:heb|heeft|hebben|had|gehad|gekregen|ontvangen|binnengekregen|zijn|is)\b",
        value,
    )
    if existence_match:
        return existence_match.group(1).strip()
    # Dutch inverted: "heb ik ... [van/from] <topic>" (e.g. "heb ik emails van github")
    inv_match = re.search(
        r"\b(?:heb|heeft|hebben|had)\s+(?:ik|we|wij|je|er)\s+.{0,40}?\b(?:van|from|over|about)\s+(.+)",
        value,
    )
    if inv_match:
        hint = re.sub(r"[?!.]+$", "", inv_match.group(1)).strip()
        if hint:
            return hint

    # "van afzender X" / "from sender X" / "van X" when in email context
    sender_match = re.search(
        r"\b(?:van\s+(?:afzender|sender)?|from(?:\s+sender)?)\s+([\w@.\-]+)",
        value,
    )
    if sender_match:
        return sender_match.group(1).strip()
    explicit_search = bool(re.search(r"\b(search|zoek|find|vind|look for|zoek naar)\b", value))
    sender_or_topic = bool(re.search(r"\b(from|over|about|subject|onderwerp|afzender)\b", value))
    quoted = bool(re.search(r'"[^"]+"|\'[^\']+\'', raw))
    if not (explicit_search or sender_or_topic or quoted):
        return ""
    return raw


def _normalize_email_search_query(query: str) -> str:
    value = str(query or "").strip()
    if not value:
        return ""
    # Remove summary/explain verbs that belong to user intent, not lookup terms.
    value = _EMAIL_SUMMARY_NOISE_RE.sub(" ", value)
    value = re.sub(r"\b(?:kun\s+je|can\s+you|wil\s+je|please)\b", " ", value, flags=re.IGNORECASE)
    value = re.sub(r"\s+", " ", value).strip(" \t\r\n.,;:!?\"'")

    # Convert verbose sender form to a concrete token when possible.
    m = re.match(r"^(?:from|van)\s+([\w@.\-]+)$", value, re.IGNORECASE)
    if m:
        return m.group(1).strip()

    # If phrase still starts with from/van and has extra clutter, keep sender token only.
    m2 = re.match(r"^(?:from|van)\s+([\w@.\-]+)\b.*$", value, re.IGNORECASE)
    if m2:
        return m2.group(1).strip()

    tokens = re.findall(r"[A-Za-z0-9@._+-]+", value.lower())
    cleaned_tokens: list[str] = []
    negate_next = False
    for token in tokens:
        if token in _EMAIL_QUERY_NEGATION_WORDS:
            negate_next = True
            continue
        if token in _EMAIL_QUERY_STOPWORDS:
            negate_next = False
            continue
        if len(token) < 3 and "@" not in token and not any(ch.isdigit() for ch in token):
            negate_next = False
            continue
        if negate_next:
            cleaned_tokens.append(f"-{token}")
            negate_next = False
        else:
            cleaned_tokens.append(token)

    expanded_tokens: list[str] = []
    for token in cleaned_tokens:
        if token not in expanded_tokens:
            expanded_tokens.append(token)
        if token.endswith(("gepland", "planned", "planning")):
            if "plan" not in expanded_tokens:
                expanded_tokens.append("plan")
        if token.startswith("in") and len(token) > 6 and token[2:] not in _EMAIL_QUERY_STOPWORDS:
            stem = token[2:]
            if stem not in expanded_tokens and len(stem) >= 4:
                expanded_tokens.append(stem)
        if "afspraak" in token and token != "afspraak" and "afspraak" not in expanded_tokens:
            expanded_tokens.append("afspraak")

    if expanded_tokens:
        # Keep compact, high-signal queries for better provider-side ranking.
        return " ".join(expanded_tokens[:8])
    return ""


def _has_explicit_email_count(text: str) -> bool:
    return bool(re.search(
        r"\b(\d{1,3})\s+(?:emails?|mails?|messages?|berichten|mailtjes)\b",
        str(text or ""),
        re.IGNORECASE,
    ))


def _detect_search_source_scope(text: str) -> str:
    value = str(text or "")
    if re.search(r"\b(onedrive|sharepoint)\b", value, re.IGNORECASE):
        return "onedrive"
    if re.search(r"\b(google drive|gdrive)\b", value, re.IGNORECASE):
        return "google_drive"
    if re.search(r"\b(internet|web|online|website|site|sites|url|urls|browser|browse|pagina|page)\b", value, re.IGNORECASE) or _BROWSER_SEARCH_RE.search(value) or _WEB_DOMAIN_RE.search(value):
        return "web"
    if re.search(r"\b(workspace|project|repo|repository|codebase|code|bestand|bestanden|file|files|folder|map|lokaal|local)\b", value, re.IGNORECASE):
        return "workspace"
    return "default"


def _is_explicit_browser_navigation_request(text: str) -> bool:
    value = str(text or "").strip()
    if not value:
        return False
    if _BROWSER_NAVIGATION_RE.search(value):
        return True
    if _WEB_DOMAIN_RE.fullmatch(value):
        return True
    if _WEB_DOMAIN_RE.search(value) and re.search(
        r"\b(?:open(?:en)?|ga|navigeer|browse|bezoek|visit)\b",
        value,
        re.IGNORECASE,
    ):
        return True
    return False


def _extract_general_search_query(text: str) -> str:
    raw = str(text or "").strip()
    if not raw:
        return ""
    quoted = re.search(r'"([^"]+)"|\'([^\']+)\'', raw)
    if quoted:
        return (quoted.group(1) or quoted.group(2) or "").strip()

    value = raw
    value = re.sub(
        r"^(?:gebruik\s+(?:je|de)\s+browser(?:\s+en)?|use\s+(?:the\s+)?browser(?:\s+and)?|ga\s+naar|navigeer\s+naar|browse\s+to|bezoek|visit|open(?:en)?\s+(?:de\s+|het\s+)?(?:website|site|pagina|page|url)?(?:\s+van)?|open\s+the\s+(?:website|site|page|url)?(?:\s+for)?)\s+",
        "",
        value,
        flags=re.IGNORECASE,
    )
    value = re.sub(r"^(?:zoek(?:\s+naar)?|search(?:\s+for)?|find|vind|look\s+for|lookup)\s+", "", value, flags=re.IGNORECASE)
    value = re.sub(r"^(?:op|on|naar|for)\s+", "", value, flags=re.IGNORECASE)
    value = re.sub(r"^(?:in|op|via)\s+", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:mijn\s+)?(?:workspace|project|repo(?:sitory)?|codebase|code|bestanden|files|folders?|map|lokaal|local)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:internet|web|online|website|site|sites|url|urls|browser|browse|pagina|page|google drive|gdrive|onedrive|sharepoint)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:document|documenten|doc|pdf|bestand|bestanden|file|files|folder|map|code|script|scripts|notitie|notities|note|notes)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:in|op|via|naar|for|about|over)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\s+", " ", value).strip(" \t\r\n.,;:!?\"'")
    return value


def _heuristic_readonly_lookup_intent(goal: str, folder_profiles: dict | None = None) -> dict | None:
    text = str(goal or "").strip()
    if not text:
        return None
    gl = text.lower()

    write_markers = re.compile(
        r"\b(stuur|sturen|verstuur|versturen|send|reply|antwoord|forward|doorsturen|compose|draft|concept|"
        r"maak|create|delete|remove|verwijder\w*|move|verplaats\w*|update|wijzig\w*|"
        r"aanpas\w*|verzet\w*|vervroeg\w*|verlaat\w*|inplan\w*|plan|schedule|"
        r"inboek\w*|book|boek|zet\w*|toevoeg\w*|voeg\s+toe|plaats\w*)\b",
        re.IGNORECASE,
    )
    if write_markers.search(text):
        return None

    calendar_markers = re.compile(
        r"\b(calendar|agenda|appointment|appointments|meeting|meetings|afspraak|afspraken|event|events)\b",
        re.IGNORECASE,
    )
    if calendar_markers.search(gl):
        explicit_date_range = _extract_explicit_date_range(text)
        intent = {
            "intent": "calendar_read",
            "search_query": "",
            "folder": "Inbox",
            "time_filter": _detect_time_filter(text),
            "unread_only": False,
            "days_ahead": _detect_calendar_days_ahead(text),
        }
        if explicit_date_range:
            intent["time_filter"] = "custom_range"
            intent["since"] = explicit_date_range["since"]
            intent["until"] = explicit_date_range["until"]
            intent["time_label"] = explicit_date_range["time_label"]
            intent["days_ahead"] = 1
        return intent

    search_markers = re.compile(
        r"\b(zoek|search|find|vind|look for|lookup|document|documenten|bestand|bestanden|file|files|folder|map|workspace|repo|repository|codebase|internet|web|website|site|browser|browse|google drive|gdrive|onedrive|sharepoint)\b",
        re.IGNORECASE,
    )
    if search_markers.search(gl) or _WEB_DOMAIN_RE.search(text):
        search_source = _detect_search_source_scope(text)
        search_query = _extract_general_search_query(text)
        if search_query and search_source != "default":
            return {
                "intent": "search_lookup",
                "search_query": search_query,
                "source_scope": search_source,
                "folder": "Inbox",
                "time_filter": "none",
                "unread_only": False,
                "days_ahead": 7,
            }

    email_markers = re.compile(
        r"\b(email|emails|mails?|gmail|outlook|inbox|sent|verzonden|drafts|concepten|deleted|trash|postvak)\b",
        re.IGNORECASE,
    )
    read_markers = re.compile(
        r"\b(check(?:en)?|toon|show|laat|bekijk|lees|read|search|zoek|welke|which|wat|what|hoeveel|how many|aantal|last|laatste|recent|recente|nieuwste|newest|latest|unread|ongelezen|verstuurd|gestuurd|verzonden|sent|ontvangen|received|gekregen|gehad|heb(?:ben)?|heeft)\b",
        re.IGNORECASE,
    )
    if not email_markers.search(gl) or not read_markers.search(gl):
        return None

    search_hint = _extract_email_search_hint(text)
    explicit_date_range = _extract_explicit_date_range(text)

    # For search intents, skip folder-profile matching — search broadly in Inbox.
    # Folder profiles are useful for inbox_read ("toon mijn huis emails"), not
    # for keyword/sender searches which should cast a wide net.
    folder = _detect_email_folder(text, folder_profiles if not search_hint else None)

    intent = {
        "intent": "email_search" if search_hint else "inbox_read",
        "search_query": search_hint,
        "folder": folder,
        "time_filter": _detect_time_filter(text),
        "unread_only": bool(re.search(r"\b(unread|ongelezen)\b", gl)),
        "days_ahead": 7,
    }
    if explicit_date_range:
        intent["time_filter"] = "custom_range"
        intent["since"] = explicit_date_range["since"]
        intent["until"] = explicit_date_range["until"]
        intent["time_label"] = explicit_date_range["time_label"]
    return intent


def _merge_direct_intent(heuristic_intent: dict | None, classified_intent: dict | None) -> dict | None:
    if not heuristic_intent:
        return classified_intent
    if not classified_intent:
        return heuristic_intent

    heuristic_action = str(heuristic_intent.get("intent") or "").strip().lower()
    classified_action = str(classified_intent.get("intent") or "").strip().lower()
    if not classified_action or classified_action == "none":
        return heuristic_intent

    # LLM classification is authoritative; heuristic only fills missing fields.
    if heuristic_action == "email_search" and classified_action == "inbox_read" and heuristic_intent.get("search_query"):
        classified_intent = dict(classified_intent)
        classified_intent["intent"] = "email_search"

    merged = dict(classified_intent)
    if heuristic_action == str(merged.get("intent") or "").strip().lower():
        merged_query = _normalize_email_search_query(str(merged.get("search_query") or ""))
        heuristic_query = _normalize_email_search_query(str(heuristic_intent.get("search_query") or ""))
        if merged.get("intent") == "email_search":
            # Prefer clean heuristic query when classifier query contains intent-noise.
            if heuristic_query and (
                not merged_query
                or _EMAIL_SUMMARY_NOISE_RE.search(str(merged.get("search_query") or ""))
            ):
                merged["search_query"] = heuristic_query
            else:
                merged["search_query"] = merged_query
        elif not str(merged.get("search_query") or "").strip() and heuristic_intent.get("search_query"):
            merged["search_query"] = heuristic_intent.get("search_query", "")
        if str(merged.get("source_scope") or "default") == "default" and str(heuristic_intent.get("source_scope") or "default") != "default":
            merged["source_scope"] = heuristic_intent.get("source_scope", "default")
        # For email_search: prefer heuristic folder (Inbox) over LLM-guessed
        # subfolder. Folder profile matching is intentionally skipped for search
        # intents so searches cast a wide net instead of narrowing to a subfolder.
        if merged.get("intent") == "email_search" and str(heuristic_intent.get("folder") or "Inbox") == "Inbox":
            merged["folder"] = "Inbox"
        elif str(merged.get("folder") or "Inbox") == "Inbox" and str(heuristic_intent.get("folder") or "Inbox") != "Inbox":
            merged["folder"] = heuristic_intent.get("folder", "Inbox")
        if str(merged.get("time_filter") or "none") == "none" and str(heuristic_intent.get("time_filter") or "none") != "none":
            merged["time_filter"] = heuristic_intent.get("time_filter", "none")
        if not str(merged.get("since") or "").strip() and str(heuristic_intent.get("since") or "").strip():
            merged["since"] = heuristic_intent.get("since", "")
        if not str(merged.get("until") or "").strip() and str(heuristic_intent.get("until") or "").strip():
            merged["until"] = heuristic_intent.get("until", "")
        if not str(merged.get("time_label") or "").strip() and str(heuristic_intent.get("time_label") or "").strip():
            merged["time_label"] = heuristic_intent.get("time_label", "")
        if not bool(merged.get("unread_only", False)) and bool(heuristic_intent.get("unread_only", False)):
            merged["unread_only"] = True
        if int(merged.get("days_ahead", 7) or 7) == 7 and int(heuristic_intent.get("days_ahead", 7) or 7) != 7:
            merged["days_ahead"] = heuristic_intent.get("days_ahead", 7)

    return merged


def _detect_requested_email_count(text: str, default: int) -> int:
    value = str(text or "")
    match = re.search(
        r"\b(\d{1,3})\s+(?:emails?|mails?|messages?|berichten|mailtjes)\b",
        value,
        re.IGNORECASE,
    )
    if not match:
        return default
    try:
        return max(1, min(int(match.group(1)), 250))
    except Exception:
        return default


def _build_direct_tool_calls(
    intent: dict,
    *,
    use_outlook: bool = False,
    gmail_available: bool = False,
    request_text: str = "",
    folder_profiles: dict | None = None,
) -> list[tuple[str, dict]]:
    action = (intent.get("intent") or "").strip().lower()
    calls: list[tuple[str, dict]] = []
    scoped_outlook, scoped_gmail, _ = _detect_email_source_scope(
        request_text or json.dumps(intent, ensure_ascii=False),
        use_outlook=use_outlook,
        gmail_available=gmail_available,
    )
    outlook_mailbox = _detect_outlook_mailbox_scope(request_text or json.dumps(intent, ensure_ascii=False))

    if action == "calendar_read":
        days = max(1, int(intent.get("days_ahead", 7) or 7))
        time_filter = (intent.get("time_filter") or "none").strip().lower()
        since = str(intent.get("since") or "").strip()
        until = str(intent.get("until") or "").strip()
        target_reference = dict(intent.get("target_reference") or {})
        temporal_hint = str(target_reference.get("temporal_hint") or "").strip().lower()
        explicit_date_range = _extract_explicit_date_range(request_text) if request_text else {}
        if explicit_date_range:
            # Current user text is authoritative for temporal constraints.
            # This prevents follow-ups like "en op 1 april?" from inheriting
            # a broader previous range such as the whole month.
            since = str(explicit_date_range.get("since") or "").strip()
            until = str(explicit_date_range.get("until") or "").strip()
            time_filter = "custom_range"
            days = 1
        now = datetime.now()
        if not since and not until and temporal_hint in {"today", "vandaag"}:
            since = now.strftime("%Y-%m-%d")
            until = since
            days = 1
        elif not since and not until and temporal_hint in {"tomorrow", "morgen"}:
            tomorrow = now + timedelta(days=1)
            since = tomorrow.strftime("%Y-%m-%d")
            until = since
            days = 1
        elif not since and not until and temporal_hint in {"yesterday", "gisteren"}:
            yesterday = now - timedelta(days=1)
            since = yesterday.strftime("%Y-%m-%d")
            until = since
            days = 1
        elif not since and not until and time_filter == "today":
            since = now.strftime("%Y-%m-%d")
            until = since
        elif not since and not until and time_filter == "yesterday":
            yesterday = now - timedelta(days=1)
            since = yesterday.strftime("%Y-%m-%d")
            until = since
        elif not since and not until and time_filter == "this_week":
            monday = now - timedelta(days=now.weekday())
            sunday = monday + timedelta(days=6)
            since = monday.strftime("%Y-%m-%d")
            until = sunday.strftime("%Y-%m-%d")
        elif not since and not until and time_filter == "last_week":
            monday = now - timedelta(days=now.weekday() + 7)
            sunday = monday + timedelta(days=6)
            since = monday.strftime("%Y-%m-%d")
            until = sunday.strftime("%Y-%m-%d")
        elif not since and not until and time_filter == "this_month":
            since = now.replace(day=1).strftime("%Y-%m-%d")
        elif not since and not until and time_filter == "last_month":
            year = now.year
            month = now.month - 1
            if month == 0:
                month = 12
                year -= 1
            since, until = _month_bounds(year, month)
        if scoped_outlook:
            args: dict[str, Any] = {"days_ahead": days}
            if since:
                args["since"] = since
            if until:
                args["until"] = until
            calls.append(("outlook_read_calendar", args))
        if scoped_gmail:
            args: dict[str, Any] = {"days_ahead": days}
            if since:
                args["since"] = since
            if until:
                args["until"] = until
            calls.append(("google_read_calendar", args))
        return calls

    if action == "search_lookup":
        source_scope = str(intent.get("source_scope") or "default").strip().lower()
        search_query = (intent.get("search_query") or "").strip()
        if not search_query:
            return calls
        if source_scope == "web":
            calls.append(("web_search", {"query": search_query}))
        elif source_scope == "google_drive":
            calls.append(("google_drive_search", {"query": search_query, "count": 20}))
        elif source_scope == "onedrive":
            calls.append(("onedrive_search", {"query": search_query, "count": 20}))
        else:
            calls.append(("search_workspace", {"pattern": search_query, "glob": "**/*", "max_results": 20}))
        return calls

    search_query = _normalize_email_search_query(intent.get("search_query") or "")
    # For email_search: trust the folder from the intent (already resolved by
    # heuristic/merge). Re-running _detect_email_folder with the search_query
    # would incorrectly route to subfolders matching the keyword.
    action = (intent.get("intent") or "").strip().lower()
    if action == "email_search" and search_query:
        folder = _detect_email_folder(str(intent.get("folder", "") or ""), None)
    else:
        folder = _detect_email_folder(str(intent.get("folder", "") or "") + " " + search_query, folder_profiles)
    time_filter = (intent.get("time_filter") or "none").strip().lower()
    unread_only = bool(intent.get("unread_only", False))
    requested_count = _detect_requested_email_count(request_text or search_query, default=20)
    if action == "email_search" and search_query and not _has_explicit_email_count(request_text or ""):
        requested_count = max(requested_count, 100)

    now = datetime.now()
    since = str(intent.get("since") or "").strip()
    until = str(intent.get("until") or "").strip()
    today_only = time_filter == "today"

    if not since and not until and time_filter == "this_week":
        monday = now - timedelta(days=now.weekday())
        since = monday.strftime("%Y-%m-%d")
    elif not since and not until and time_filter == "last_week":
        monday = now - timedelta(days=now.weekday() + 7)
        sunday = monday + timedelta(days=6)
        since = monday.strftime("%Y-%m-%d")
        until = sunday.strftime("%Y-%m-%d")
    elif not since and not until and time_filter == "yesterday":
        yesterday = now - timedelta(days=1)
        since = yesterday.strftime("%Y-%m-%d")
        until = yesterday.strftime("%Y-%m-%d")
    elif not since and not until and time_filter == "this_month":
        since = now.replace(day=1).strftime("%Y-%m-%d")
    elif not since and not until and time_filter == "last_month":
        year = now.year
        month = now.month - 1
        if month == 0:
            month = 12
            year -= 1
        since, until = _month_bounds(year, month)

    if action == "email_search" and search_query:
        if scoped_outlook:
            args: dict = {"query": search_query, "count": min(requested_count, 250), "mailbox": outlook_mailbox, "folder": folder}
            if today_only:
                args["today_only"] = True
            if since:
                args["since"] = since
            if until:
                args["until"] = until
            calls.append(("outlook_search_emails", args))
        if scoped_gmail:
            gmail_q_parts = [search_query]
            if today_only:
                gmail_q_parts.insert(0, "newer_than:1d")
            elif since:
                gmail_q_parts.insert(0, f"after:{since}")
                if until:
                    gmail_q_parts.insert(1, f"before:{_gmail_before_value(until)}")
            calls.append(("gmail_search_emails", {"query": " ".join(gmail_q_parts), "count": min(requested_count, 100), "folder": folder}))
    elif action == "inbox_read":
        if scoped_outlook:
            args: dict = {"count": min(requested_count, 250), "folder": folder, "mailbox": outlook_mailbox}
            if today_only:
                args["today_only"] = True
            if unread_only:
                args["unread_only"] = True
            if since:
                args["since"] = since
            if until:
                args["until"] = until
            calls.append(("outlook_get_inbox", args))
        if scoped_gmail:
            args: dict = {"count": min(requested_count, 100), "folder": folder}
            if unread_only:
                args["unread_only"] = True
            if today_only:
                args["today_only"] = True
            if since:
                args["since"] = since
            if until:
                args["until"] = until
            calls.append(("gmail_get_inbox", args))

    return calls


def _format_direct_result(goal: str, results: list[tuple[str, str]]) -> str:
    gl = goal.lower()
    is_count = bool(re.search(r"\b(hoeveel|count|how many|number of|aantal|tel)\b", gl, re.IGNORECASE))

    parts: list[str] = []
    total_email_count = 0

    for tool_name, result in results:
        txt = result.strip()
        if not txt:
            continue
        is_email_tool = any(k in tool_name for k in ("inbox", "email", "gmail"))
        if is_email_tool:
            if txt.lower().startswith("no email"):
                pass
            else:
                count = len(re.findall(r"(?m)^(?:Mailbox:|From:)\s", txt))
                total_email_count += count
                if not is_count:
                    parts.append(txt)
        else:
            parts.append(txt)

    if is_count:
        if re.search(r"[a-z]*(?:hoeveel|aantal|berichten|ontvangen)", gl):
            return f"{total_email_count} emails gevonden."
        return f"{total_email_count} emails found."

    if not parts:
        return "No results found."
    return "\n\n---\n\n".join(parts)


def _named_direct_calls(calls: list[tuple[str, dict]]) -> list[DirectToolCall]:
    return [{"tool": tool_name, "args": dict(tool_args)} for tool_name, tool_args in calls]


def _build_direct_tool_calls_from_frame(
    frame,
    *,
    use_outlook: bool = False,
    gmail_available: bool = False,
    request_text: str = "",
    folder_profiles: dict | None = None,
) -> list[tuple[str, dict]]:
    direct_intent = intent_frame_to_direct_intent(frame)
    if direct_intent is None:
        return []
    return _build_direct_tool_calls(
        direct_intent,
        use_outlook=use_outlook,
        gmail_available=gmail_available,
        request_text=request_text,
        folder_profiles=folder_profiles,
    )


def resolve_direct_acceleration(
    goal: str,
    *,
    stored_task_state: dict | None = None,
    classified_intent: dict | None = None,
    is_action: bool | None = None,
    use_outlook: bool = False,
    gmail_available: bool = False,
    folder_profiles: dict | None = None,
) -> DirectAccelerationResult:
    direct_goal = goal
    display_goal = ""
    # Use LLM classifier hint when available; fall back to regex heuristic
    _is_action = is_action if is_action is not None else _is_write_or_action_request(goal)
    if _is_action:
        return {
            "goal": direct_goal,
            "display_goal": display_goal,
            "intent": None,
            "calls": [],
        }
    if PLAYWRIGHT_SUPPORT and _is_explicit_browser_navigation_request(goal):
        return {
            "goal": direct_goal,
            "display_goal": display_goal,
            "intent": None,
            "calls": [],
        }
    direct_frame = build_intent_frame(stored_task_state) if stored_task_state else None
    if stored_task_state:
        display_goal = intent_frame_display_goal(direct_frame) or task_state_display_goal(stored_task_state) or ""

    # When task_state produced a basic email-read frame without a search query,
    # also consult the heuristic — it may detect a richer email_search intent
    # (e.g. Dutch "of ik vve jaarstukken heb" → search_query "vve jaarstukken").
    if (
        direct_frame is not None
        and direct_frame.domain == "email"
        and not direct_frame.search_query
        and not (direct_frame.target_entity or {}).get("text")
    ):
        _heuristic = _heuristic_readonly_lookup_intent(goal, folder_profiles)
        if _heuristic and str(_heuristic.get("search_query") or ""):
            direct_frame = intent_frame_from_direct_intent(_heuristic, source_text=goal)

    if direct_frame is None:
        direct_frame = intent_frame_from_direct_intent(_heuristic_readonly_lookup_intent(goal, folder_profiles), source_text=goal)
    direct_intent = intent_frame_to_direct_intent(direct_frame)
    if (
        classified_intent
        and isinstance(classified_intent, dict)
        and str(classified_intent.get("intent") or "none").strip().lower() != "none"
        and stored_task_state is None
    ):
        merged_intent = _merge_direct_intent(direct_intent, classified_intent)
        direct_frame = intent_frame_from_direct_intent(merged_intent, source_text=goal)
        direct_intent = intent_frame_to_direct_intent(direct_frame)

    direct_calls: list[DirectToolCall] = []
    if direct_frame is not None:
        direct_calls = _named_direct_calls(
            _build_direct_tool_calls_from_frame(
                direct_frame,
                use_outlook=use_outlook,
                gmail_available=gmail_available,
                request_text=direct_goal,
                folder_profiles=folder_profiles,
            )
        )

    return {
        "goal": direct_goal,
        "display_goal": display_goal,
        "intent": direct_intent,
        "calls": direct_calls,
    }