import copy
import re
from datetime import datetime, timedelta

from backend.action_intents import build_reference_spec, extract_temporal_reference_hint
from backend.appointment_intents import merge_appointment_intents, parse_appointment_intent
from backend.copilot_briefing import is_copilot_morning_brief_goal, is_copilot_followup_goal
from backend.email_intents import parse_email_intent
from backend.email_intents import merge_email_intents
from backend.intent_frames import (
    appointment_intent_from_frame,
    appointment_intent_to_frame,
    build_intent_frame,
    email_intent_from_frame,
    email_intent_to_frame,
    intent_frame_display_goal,
    intent_frame_to_direct_intent,
    task_state_from_intent_frame,
)


_EMAIL_MARKERS_RE = re.compile(
    r"\b(email|emails|mail|mails|gmail|inbox|postvak|e-mail|sent|verzonden|berichten?)\b",
    re.IGNORECASE,
)
_CALENDAR_MARKERS_RE = re.compile(
    r"\b(calendar|agenda|afspraak|afspraken|meeting|meetings|event|events)\b",
    re.IGNORECASE,
)
_COUNT_MARKERS_RE = re.compile(r"\b(hoeveel|aantal|count|how many|number of|tel)\b", re.IGNORECASE)
_EMAIL_CONTENT_MARKERS_RE = re.compile(
    r"\b(samenvat(?:ten|ting)?|summary|inhoud|inhoudelijk|waar\s+gaat|waarover|"
    r"what\s+is\s+it\s+about|what\s+does\s+it\s+say|content|details?|uitleg(?:gen)?|leg\s+uit)\b",
    re.IGNORECASE,
)
_PROVIDER_OUTLOOK_RE = re.compile(r"\boutlook\b", re.IGNORECASE)
_PROVIDER_GMAIL_RE = re.compile(r"\b(gmail|google mail|google agenda|google calendar)\b", re.IGNORECASE)
_BOTH_PROVIDERS_RE = re.compile(
    r"\b(beide|both|allebei|all accounts|across accounts|combine|merge|vergelijk|compare)\b",
    re.IGNORECASE,
)
_PRONOUN_ENTITY_RE = re.compile(r"\b(hun|hen|hem|haar|ze|them|their|those|die|deze|dat)\b", re.IGNORECASE)
_CORRECTION_RE = re.compile(
    r"\b(nee|no|niet|bedoel ik|ik bedoel|i mean|ik wil|gebruik|doe|pak|liever|instead|in plaats van|ipv)\b",
    re.IGNORECASE,
)
_CONTRASTIVE_RE = re.compile(
    r"\b(de\s+andere|the\s+other|niet\s+die|niet\s+deze|not\s+that\s+one|"
    r"andere\s+\w+|other\s+\w+|maar\s+die|but\s+the|wrong\s+\w+|verkeerde)\b",
    re.IGNORECASE,
)
_SEARCH_VERB_RE = re.compile(
    r"\b(zoek|search|find|vind|look for|lookup|zoek naar|find me|finde?)\b",
    re.IGNORECASE,
)
_SEARCH_WORKSPACE_RE = re.compile(
    r"\b(workspace|project|repo|repository|codebase|code|bestand|bestanden|file|files|folder|map|lokaal|local)\b",
    re.IGNORECASE,
)
_SEARCH_WEB_RE = re.compile(r"\b(internet|web|online|website|site|sites|url|urls|browser|browse|pagina|page)\b", re.IGNORECASE)
_SEARCH_BROWSER_RE = re.compile(
    r"\b(?:gebruik\s+(?:je|de)\s+browser|use\s+(?:the\s+)?browser|ga\s+naar|navigeer\s+naar|browse\s+to|open(?:en)?\s+(?:de\s+|het\s+)?(?:website|site|pagina|url))\b",
    re.IGNORECASE,
)
_SEARCH_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,
)
_SEARCH_GOOGLE_DRIVE_RE = re.compile(r"\b(google drive|gdrive)\b", re.IGNORECASE)
_SEARCH_ONEDRIVE_RE = re.compile(r"\b(onedrive|sharepoint)\b", re.IGNORECASE)
_SEARCH_OBJECT_RE = re.compile(
    r"\b(document|documenten|doc|pdf|bestand|bestanden|file|files|map|folder|code|script|scripts|notitie|notities|note|notes)\b",
    re.IGNORECASE,
)
_WRITE_ACTION_RE = re.compile(
    r"\b("
    r"verplaats|move|"
    r"wijzig\w*|change|update|edit|aanpas\w*|verzet\w*|vervroeg\w*|verlaat\w*|"
    r"maak|create|schedule|inplan\w*|plan|inboek\w*|boek|"
    r"zet\w*|toevoeg\w*|voeg\s+toe|plaats|"
    r"verwijder\w*|delete|remove|"
    r"stuur|sturen|versturen|send|reply|antwoord|forward|doorstuur|"
    r"compose|draft"
    r")\b",
    re.IGNORECASE,
)
_DATE_CORRECTION_RE = re.compile(
    r"\b(?:het\s+is|it's|it\s+is)\s+(?:vandaag|today|gisteren|yesterday|morgen|tomorrow|deze\s+week|this\s+week|vorige\s+week|last\s+week|deze\s+maand|this\s+month|vorige\s+maand|last\s+month)\b",
    re.IGNORECASE,
)
_EMAIL_CONTEXT_REFERENCE_RE = re.compile(
    r"([^.?!]{10,220})[.?!]?\s*(?:daar|daarover|there|about\s+that)\s+heb(?:ben)?\s+(?:ik|we|i)\s+(?:een\s+)?(?:e-?mail|mail)\s+(?:van|over)",
    re.IGNORECASE,
)
_EMAIL_LOOKUP_CUE_RE = re.compile(
    r"\b(?:zoek|opzoek|opzoeken|search|find|vind|lookup|look\s+for)\b.{0,50}\b(?:email|e-mail|mail|inbox|postvak|berichten?)\b"
    r"|\b(?:email|e-mail|mail|inbox|postvak|berichten?)\b.{0,50}\b(?:zoek|opzoek|opzoeken|search|find|vind|lookup|look\s+for)\b"
    r"|\b(?:heb|heeft|hebben|had|gehad)\b.{0,50}\b(?:een\s+)?(?:email|e-mail|mail)\s+van\b",
    re.IGNORECASE,
)
_CALENDAR_LOOKUP_CUE_RE = re.compile(
    r"\b(?:zoek|search|find|vind|lookup|look\s+for|toon|show|welke|what)\b.{0,50}\b(?:agenda|calendar|afspraak|afspraken|meeting|meetings|event|events)\b"
    r"|\b(?:agenda|calendar|afspraak|afspraken|meeting|meetings|event|events)\b.{0,50}\b(?:zoek|search|find|vind|lookup|look\s+for|toon|show|welke|what)\b",
    re.IGNORECASE,
)
_NEGATED_EMAIL_DOMAIN_RE = re.compile(
    r"\b(?:niet|geen|no)\b\s*(?:in|op|via)?\s*(?:mijn\s+)?(?:email|e-mail|mail|inbox|postvak|berichten?)\b",
    re.IGNORECASE,
)
_NEGATED_CALENDAR_DOMAIN_RE = re.compile(
    r"\b(?:niet|geen|no)\b\s*(?:in|op|via)?\s*(?:mijn\s+)?(?:agenda|calendar|afspraak|afspraken|meeting|meetings|event|events)\b",
    re.IGNORECASE,
)

_MONTH_NAME_TO_NUMBER = {
    "januari": 1,
    "january": 1,
    "februari": 2,
    "february": 2,
    "maart": 3,
    "march": 3,
    "april": 4,
    "mei": 5,
    "may": 5,
    "juni": 6,
    "june": 6,
    "juli": 7,
    "july": 7,
    "augustus": 8,
    "august": 8,
    "september": 9,
    "oktober": 10,
    "october": 10,
    "november": 11,
    "december": 12,
}
_MONTH_NAME_PATTERN = "|".join(sorted(_MONTH_NAME_TO_NUMBER.keys(), key=len, reverse=True))

_WEEKDAY_NAMES_TO_INDEX = {
    "maandag": 0, "monday": 0,
    "dinsdag": 1, "tuesday": 1,
    "woensdag": 2, "wednesday": 2,
    "donderdag": 3, "thursday": 3,
    "vrijdag": 4, "friday": 4,
    "zaterdag": 5, "saturday": 5,
    "zondag": 6, "sunday": 6,
}
_WEEKDAY_NAME_PATTERN = "|".join(sorted(_WEEKDAY_NAMES_TO_INDEX.keys(), key=len, reverse=True))


def _month_bounds(year: int, month: int) -> tuple[str, str]:
    start = datetime(year, month, 1)
    if month == 12:
        end = datetime(year + 1, 1, 1) - timedelta(days=1)
    else:
        end = datetime(year, month + 1, 1) - timedelta(days=1)
    return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")


def _infer_month_year(month: int, explicit_year: int | None = None) -> int:
    if explicit_year:
        return explicit_year
    now = datetime.now()
    # If the referenced month is at most 2 months before the current month,
    # assume this year (user likely means recent past).  Otherwise assume
    # previous year (e.g. "december" when it's January → last year).
    months_behind = now.month - month  # positive when month < now.month
    if months_behind <= 0:
        # Month is current or future → this year
        return now.year
    if months_behind <= 2:
        # 1-2 months ago → this year (recent past)
        return now.year
    # 3+ months ago → previous year
    return now.year - 1


def _extract_explicit_date_range(text: str) -> dict[str, str]:
    value = str(text or "").strip().lower()
    if not value:
        return {}

    weekday_match = re.search(rf"\b({_WEEKDAY_NAME_PATTERN})\b", value, re.IGNORECASE)
    if weekday_match:
        day_name = weekday_match.group(1).lower()
        target_index = _WEEKDAY_NAMES_TO_INDEX.get(day_name)
        if target_index is not None:
            now = datetime.now()
            delta = (target_index - now.weekday()) % 7
            if delta == 0:
                delta = 7
            target_day = now + timedelta(days=delta)
            date_value = target_day.strftime("%Y-%m-%d")
            return {"since": date_value, "until": date_value, "time_label": day_name}

    relative_day_match = re.search(
        r"\b(\d{1,2})\s+(?:dagen|days)\s+geleden\b|\b(\d{1,2})\s+days?\s+ago\b",
        value,
        re.IGNORECASE,
    )
    if relative_day_match:
        days_back = int(relative_day_match.group(1) or relative_day_match.group(2) or 0)
        if days_back > 0:
            target_day = datetime.now() - timedelta(days=days_back)
            since = target_day.strftime("%Y-%m-%d")
            until = since
            label = f"{days_back} dagen geleden" if "dagen geleden" in value else f"{days_back} days ago"
            return {"since": since, "until": until, "time_label": label}

    if re.search(r"\b(vorige maand|last month)\b", value):
        now = datetime.now()
        year = now.year
        month = now.month - 1
        if month == 0:
            month = 12
            year -= 1
        since, until = _month_bounds(year, month)
        label = f"{year}-{month:02d}"
        return {"since": since, "until": until, "time_label": label}

    day_match = re.search(
        rf"\b(?:op|on|voor|van|uit|during|from)?\s*(\d{{1,2}})\s+({_MONTH_NAME_PATTERN})(?:\s+(\d{{4}}))?\b",
        value,
        re.IGNORECASE,
    )
    if day_match:
        day = int(day_match.group(1))
        month_name = str(day_match.group(2) or "").lower()
        month = _MONTH_NAME_TO_NUMBER.get(month_name)
        explicit_year = int(day_match.group(3)) if day_match.group(3) else None
        if month:
            year = _infer_month_year(month, explicit_year)
            try:
                exact_day = datetime(year, month, day)
                since = exact_day.strftime("%Y-%m-%d")
                until = since
                label = f"{day} {month_name} {year}"
                return {"since": since, "until": until, "time_label": label}
            except ValueError:
                pass

    match = re.search(
        rf"\b(?:in|uit|van|voor|during|from)\s+({_MONTH_NAME_PATTERN})(?:\s+(\d{{4}}))?\b",
        value,
        re.IGNORECASE,
    )
    if not match:
        return {}
    month_name = str(match.group(1) or "").lower()
    month = _MONTH_NAME_TO_NUMBER.get(month_name)
    if not month:
        return {}
    explicit_year = int(match.group(2)) if match.group(2) else None
    year = _infer_month_year(month, explicit_year)
    since, until = _month_bounds(year, month)
    label = f"{month_name} {year}"
    return {"since": since, "until": until, "time_label": label}


def _detect_time_filter(text: str) -> str:
    value = str(text or "").lower()
    if re.search(r"\b(vandaag|today)\b", value):
        return "today"
    if re.search(r"\b(gisteren|yesterday)\b", value):
        return "yesterday"
    if re.search(r"\b(deze week|this week)\b", value):
        return "this_week"
    if re.search(r"\b(vorige week|last week)\b", value):
        return "last_week"
    if re.search(r"\b(deze maand|this month)\b", value):
        return "this_month"
    if re.search(r"\b(vorige maand|last month)\b", value):
        return "last_month"
    return "none"


def _detect_folder(text: str, default: str = "Inbox", folder_profiles: dict | None = None) -> str:
    value = str(text or "").lower()
    if re.search(r"\b(sent|verzonden|verstuurd|gestuurd)\b", value):
        return "Sent"
    if re.search(r"\b(drafts|concepten|draft)\b", value):
        return "Drafts"
    if re.search(r"\b(deleted|trash|prullenbak|verwijderd)\b", value):
        return "Deleted"
    if folder_profiles:
        best: str | None = None
        best_score = 0
        for folder_path, description in folder_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:
                seg_lower = seg.lower()
                if re.search(r"\b" + re.escape(seg_lower) + r"\b", value):
                    score += len(seg_lower) + len(segments)
            if score == 0 and description:
                desc_lower = description.lower()
                for word in value.split():
                    if len(word) >= 4 and word in desc_lower:
                        score += len(word)
            if score > best_score:
                best_score = score
                best = folder_path
        if best:
            return best
    return default


def _detect_calendar_days(text: str) -> int:
    value = str(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
    return 7


def _has_calendar_horizon_hint(text: str) -> bool:
    value = str(text or "").lower()
    if re.search(r"\b(vandaag|today|morgen|tomorrow|deze week|this week|volgende week|next week)\b", value):
        return True
    if re.search(rf"\b({_WEEKDAY_NAME_PATTERN})\b", value, re.IGNORECASE):
        return True
    return False


def _detect_provider_scope(text: str, prior_scope: str = "") -> str:
    value = str(text or "")
    if _BOTH_PROVIDERS_RE.search(value):
        return "cross_provider"
    has_outlook = bool(_PROVIDER_OUTLOOK_RE.search(value))
    has_gmail = bool(_PROVIDER_GMAIL_RE.search(value))
    if has_outlook and not has_gmail:
        return "outlook_only"
    if has_gmail and not has_outlook:
        return "gmail_only"
    return prior_scope or "default"


def _clean_entity_text(text: str) -> str:
    value = str(text or "").strip(" \t\r\n.,;:!?\"'")
    value = re.sub(r"^(?:de|het|een|the)\s+", "", value, flags=re.IGNORECASE)
    value = re.sub(
        r"\s+(?:in|via)\s+(?:gmail|google mail|outlook)\b.*$",
        "",
        value,
        flags=re.IGNORECASE,
    )
    value = re.sub(
        rf"\s+(?:vandaag|today|gisteren|yesterday|deze week|this week|vorige week|last week|deze maand|this month|vorige maand|last month|in\s+(?:{_MONTH_NAME_PATTERN})(?:\s+\d{{4}})?)\b.*$",
        "",
        value,
        flags=re.IGNORECASE,
    )
    value = re.sub(r"\s+(?:heb ik|have i|ik heb)\b.*$", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\s+(?:bedoel ik|please|graag)$", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\s+(?:gekregen|ontvangen|binnengekregen|gehad|received|gotten|had)\s*$", "", value, flags=re.IGNORECASE)
    return value.strip()


def _detect_search_source(text: str, prior_source: str = "") -> str:
    value = str(text or "")
    if _SEARCH_ONEDRIVE_RE.search(value):
        return "onedrive"
    if _SEARCH_GOOGLE_DRIVE_RE.search(value):
        return "google_drive"
    if _SEARCH_WEB_RE.search(value) or _SEARCH_BROWSER_RE.search(value) or _SEARCH_DOMAIN_RE.search(value):
        return "web"
    if _SEARCH_WORKSPACE_RE.search(value):
        return "workspace"
    return prior_source or "default"


def _extract_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|open(?:en)?\s+(?:de\s+|het\s+)?(?:website|site|pagina|url)?(?:\s+van)?|open\s+the\s+(?:website|site|page|url)?(?:\s+for)?)\s+",
        "",
        value,
        flags=re.IGNORECASE,
    )
    value = re.sub(r"^(?:nee|no|niet|bedoel ik|ik wil|gebruik|doe|pak|liever|instead|in plaats van|ipv)\s*,?\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"^\s*(?:naar|for)\s+", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:in|op|via)\s+(?:mijn\s+)?(?:workspace|project|repo(?:sitory)?|codebase|code|bestanden|files|folders?|map|lokaal|local)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:op|via)\s+(?:het\s+)?(?:internet|web|online|website|site|sites|url|urls|browser|pagina|page)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:in|op|via)\s+(?:google drive|gdrive|onedrive|sharepoint)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"^\s*(?:naar|for)\s+", "", 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(rf"\b(?:vandaag|today|gisteren|yesterday|deze week|this week|vorige week|last week|deze maand|this month|vorige maand|last month|{_MONTH_NAME_PATTERN})\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\b(?:in|op|via)\b", "", value, flags=re.IGNORECASE)
    value = re.sub(r"\s+", " ", value).strip(" \t\r\n.,;:!?\"'")
    return value


def _is_search_request(text: str, prior_task_type: str, followup_relation: str) -> bool:
    value = str(text or "")
    if prior_task_type == "search_lookup" and followup_relation != "new_task":
        return True
    has_search_signal = bool(_SEARCH_VERB_RE.search(value))
    has_search_source = bool(
        _SEARCH_WORKSPACE_RE.search(value)
        or _SEARCH_WEB_RE.search(value)
        or _SEARCH_BROWSER_RE.search(value)
        or _SEARCH_DOMAIN_RE.search(value)
        or _SEARCH_GOOGLE_DRIVE_RE.search(value)
        or _SEARCH_ONEDRIVE_RE.search(value)
    )
    has_search_object = bool(_SEARCH_OBJECT_RE.search(value))
    return has_search_signal and (has_search_source or has_search_object or bool(_extract_search_query(value)))


def _extract_target_entity(text: str) -> dict:
    raw = str(text or "").strip()
    if not raw:
        return {}
    topic_patterns = [
        r"\b(?:emails?|mails?|berichten?|mail|email|e-mail)\s+(?:over|about)\s+(.+?)(?:\s+(?:gehad|ontvangen|received|gekregen|gevonden|had)\b|[?.,;]|$)",
        r"\b(?:onderwerp|subject)\s+(.+?)(?:\s+(?:gehad|ontvangen|received|gekregen|gevonden|had)\b|[?.,;]|$)",
    ]
    for pattern in topic_patterns:
        match = re.search(pattern, raw, re.IGNORECASE)
        if not match:
            continue
        candidate = _clean_entity_text(match.group(1))
        if not candidate:
            continue
        return {"kind": "subject", "text": candidate, "confidence": 0.76}

    patterns = [
        r"\b(?:emails?|mails?|berichten?)\s+van\s+([^?.,;]+)",
        r"\b(?:emails?|mails?|berichten?)\s+from\s+([^?.,;]+)",
        r"\b(?:email|e-mail|mail|bericht|berichten)\b[^?.,;]{0,24}\bvan\s+([^?.,;]+)",
        r"\b(?:email|e-mail|mail|bericht|berichten)\b[^?.,;]{0,24}\bfrom\s+([^?.,;]+)",
        r"\bafzender\s+([^?.,;]+)",
        r"\bsender\s+([^?.,;]+)",
    ]
    for pattern in patterns:
        match = re.search(pattern, raw, re.IGNORECASE)
        if not match:
            continue
        candidate = _clean_entity_text(match.group(1))
        if not candidate:
            continue
        if re.match(
            rf"^(vandaag|today|gisteren|yesterday|deze week|this week|vorige week|last week|deze maand|this month|vorige maand|last month|{_MONTH_NAME_PATTERN})\b",
            candidate,
            re.IGNORECASE,
        ):
            continue
        if re.match(r"^(hun|hen|hem|haar|ze|them|their|those|die|deze|dat)\b", candidate, re.IGNORECASE):
            return {"kind": "reference", "text": candidate, "confidence": 0.4}
        kind = "email_address" if "@" in candidate else "sender"
        return {"kind": kind, "text": candidate, "confidence": 0.9 if kind == "email_address" else 0.78}

    contextual = _EMAIL_CONTEXT_REFERENCE_RE.search(raw)
    if contextual:
        candidate = _clean_entity_text(str(contextual.group(1) or ""))
        candidate = re.sub(r"^(?:ik|we|wij|i)\s+heb(?:ben)?\s+", "", candidate, flags=re.IGNORECASE)
        candidate = re.sub(r"\b(?:opzoeken|zoeken|search|find)\b", " ", candidate, flags=re.IGNORECASE)
        candidate = re.sub(r"\s+", " ", candidate).strip(" \t\r\n.,;:!?\"'")
        if candidate and len(candidate) >= 4:
            return {"kind": "subject", "text": candidate, "confidence": 0.72}
    return {}


def _extract_calendar_target_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()
    title_match = re.search(r"\b(?:genaamd|called|titel|title)\s+(.+?)(?:[?.,;]|$)", raw, re.IGNORECASE)
    if title_match:
        return str(title_match.group(1) or "").strip(" \t\r\n.,;:!?'\"")
    noun_match = re.search(
        r"\b(?:mijn|my|de|het)?\s*(.+?)\s+(?:afspraak|afspraken|meeting|meetings|event|events)\b",
        raw,
        re.IGNORECASE,
    )
    if noun_match:
        candidate = str(noun_match.group(1) or "").strip(" \t\r\n.,;:!?'\"")
        candidate = re.sub(r"^(?:wat\s+is|welke|toon|laat\s+zien|show|what\s+is)\s+", "", candidate, flags=re.IGNORECASE)
        candidate = re.sub(r"^(?:mijn|my)\s+", "", candidate, flags=re.IGNORECASE)
        candidate = re.sub(r"^(?:eerste|tweede|derde|vierde|vijfde|first|second|third|fourth|fifth|laatste|last)\s+", "", candidate, flags=re.IGNORECASE)
        if candidate and not re.match(r"^(?:mijn|my|de|het)$", candidate, re.IGNORECASE):
            return candidate
    return ""


def _extract_lookup_reference(text: str, *, domain: str, entity: dict | None = None, prior_reference: dict | None = None) -> dict:
    entity = dict(entity or {})
    prior_reference = dict(prior_reference or {})
    query = ""
    if domain == "email":
        query = str(entity.get("text") or "").strip() if str(entity.get("kind") or "") != "reference" else ""
    elif domain == "calendar":
        query = _extract_calendar_target_query(text)
    spec = build_reference_spec(text, query)
    if not spec.temporal_hint:
        spec.temporal_hint = extract_temporal_reference_hint(text)
    if not spec.kind and prior_reference:
        return copy.deepcopy(prior_reference)
    return spec.to_dict()


def _infer_followup_relation(text: str, prior_task: dict) -> str:
    value = str(text or "")
    if not prior_task:
        return "new_task"
    prior_task_type = str(prior_task.get("task_type") or "")
    provider_followup_hint = bool(_PROVIDER_OUTLOOK_RE.search(value) or _PROVIDER_GMAIL_RE.search(value))
    if prior_task_type == "search_lookup":
        if _CORRECTION_RE.search(value) and _detect_search_source(value):
            return "correction"
        if _extract_search_query(value):
            return "refinement"
    if prior_task_type in {"email_lookup", "calendar_lookup"} and provider_followup_hint:
        compact = re.sub(r"\s+", " ", value).strip().lower()
        if re.match(
            r"^(?:en\s+)?(?:welke\s+|which\s+|wat\s+|what\s+)?(?:(?:alleen|only|juist)\s+)?(?:in|via|op)?\s*(?:mijn\s+)?(?:gmail|google mail|google agenda|google calendar|outlook)\b",
            compact,
        ):
            return "constraint_change"
    if _CORRECTION_RE.search(value) and (_PROVIDER_OUTLOOK_RE.search(value) or _PROVIDER_GMAIL_RE.search(value)):
        return "correction"
    # Contrastive corrections: "the OTHER Jane", "niet die, de andere"
    if _CONTRASTIVE_RE.search(value) and prior_task:
        return "correction"
    if _PRONOUN_ENTITY_RE.search(value):
        return "reference_to_previous_entity"
    if _extract_target_entity(value):
        return "refinement"
    if _extract_explicit_date_range(value):
        return "constraint_change"
    if _DATE_CORRECTION_RE.search(value):
        return "constraint_change"
    if _detect_time_filter(value) != "none" or _has_calendar_horizon_hint(value):
        return "constraint_change"
    return "new_task"


def _disambiguate_lookup_domain(
    text: str,
    *,
    prior_task_type: str,
    followup_relation: str,
    is_email: bool,
    is_calendar: bool,
) -> tuple[bool, bool]:
    if not is_email or not is_calendar:
        return is_email, is_calendar

    value = str(text or "")
    email_cue = bool(_EMAIL_LOOKUP_CUE_RE.search(value))
    calendar_cue = bool(_CALENDAR_LOOKUP_CUE_RE.search(value))
    neg_email = bool(_NEGATED_EMAIL_DOMAIN_RE.search(value))
    neg_calendar = bool(_NEGATED_CALENDAR_DOMAIN_RE.search(value))

    if neg_calendar and not neg_email:
        return True, False
    if neg_email and not neg_calendar:
        return False, True

    if email_cue and not calendar_cue:
        return True, False
    if calendar_cue and not email_cue:
        return False, True

    if followup_relation == "correction":
        if prior_task_type == "calendar_lookup" and _EMAIL_MARKERS_RE.search(value):
            return True, False
        if prior_task_type == "email_lookup" and _CALENDAR_MARKERS_RE.search(value):
            return False, True

    return is_email, is_calendar


def _clone_prior_task(session: dict) -> dict:
    prior = (session or {}).get("task_state") or {}
    if isinstance(prior, dict):
        return copy.deepcopy(prior)
    return {}


def _email_intent_has_material(intent) -> bool:
    if intent is None:
        return False
    return any([
        str(getattr(intent, "action", "") or "").strip(),
        str(getattr(intent, "provider", "") or "").strip(),
        str(getattr(intent, "selection_query", "") or "").strip(),
        str(getattr(intent, "reference_kind", "") or "").strip(),
        getattr(intent, "reference_ordinal", None) is not None,
        str(getattr(intent, "reference_temporal_hint", "") or "").strip(),
        str(getattr(intent, "to", "") or "").strip(),
        str(getattr(intent, "cc", "") or "").strip(),
        str(getattr(intent, "subject", "") or "").strip(),
        str(getattr(intent, "body", "") or "").strip(),
        str(getattr(intent, "body_instruction", "") or "").strip(),
    ])


def _prior_email_action_intent(task_state: dict | None):
    frame = build_intent_frame(task_state)
    if frame is None or frame.domain != "email":
        return None
    if not str(frame.action or frame.operation or "").strip():
        return None
    intent = email_intent_from_frame(frame)
    return intent if str(getattr(intent, "action", "") or "").strip() else None


def _appointment_intent_has_material(intent) -> bool:
    if intent is None:
        return False
    return any([
        str(getattr(intent, "action", "") or "").strip(),
        str(getattr(intent, "selection_query", "") or "").strip(),
        str(getattr(intent, "reference_kind", "") or "").strip(),
        getattr(intent, "reference_ordinal", None) is not None,
        str(getattr(intent, "reference_temporal_hint", "") or "").strip(),
        str(getattr(intent, "summary", "") or "").strip(),
        str(getattr(intent, "start", "") or "").strip(),
        str(getattr(intent, "end", "") or "").strip(),
        str(getattr(intent, "location", "") or "").strip(),
        str(getattr(intent, "description", "") or "").strip(),
        str(getattr(intent, "attendees", "") or "").strip(),
    ])


def _prior_calendar_action_intent(task_state: dict | None):
    frame = build_intent_frame(task_state)
    if frame is None or frame.domain != "calendar":
        return None
    if not str(frame.action or frame.operation or "").strip():
        return None
    intent = appointment_intent_from_frame(frame)
    return intent if str(getattr(intent, "action", "") or "").strip() else None


def ensure_session_task_state(session: dict, fallback_text: str = "") -> dict | None:
    current = session.get("task_state") if isinstance(session.get("task_state"), dict) else None

    # ── Classify only the fresh message ───────────────────────────────
    # If the fresh text is a recognisable task, adopt it.  Otherwise
    # clear any stale task_state so the orchestrator follows the LLM
    # router's decision instead of re-executing an old lookup.
    primary_text = (str(fallback_text).strip() or str(session.get("goal") or "").strip())
    if primary_text:
        fresh = resolve_task_turn(primary_text, session)
        if fresh:
            session["task_state"] = fresh
            return fresh
        # Fresh text is conversational / generic — clear stale state.
        session.pop("task_state", None)
        return None

    # No fresh text available — keep existing task_state (if any).
    return current


def task_state_domain(task_state: dict | None) -> str:
    task = dict(task_state or {})
    task_type = str(task.get("task_type") or "").strip().lower()
    if task_type in {"email_lookup", "email_action"}:
        return "email"
    if task_type in {"calendar_lookup", "calendar_action"}:
        return "calendar"
    if task_type == "search_lookup":
        return "search"
    return "generic"


def task_state_requires_live_data(task_state: dict | None) -> bool:
    return bool((task_state or {}).get("requires_live_data", False))


# ── Compound instruction splitting ───────────────────────────────────────────
_COMPOUND_SPLIT_RE = re.compile(
    r"(?:\.\s+(?:en\s+|and\s+|dan\s+|then\s+|also\s+|ook\s+|daarna\s+)?)"
    r"|(?:\s+(?:en\s+dan|and\s+then|daarna|thereafter)\s+)"
    r"|(?:\s*;\s+)",
    re.IGNORECASE,
)
# Verbs that signal an independent action (not a conjunction inside a phrase)
_ACTION_VERB_RE = re.compile(
    r"^\s*(?:stuur|send|maak|create|zoek|search|find|verwijder|delete|open|"
    r"lees|read|schrijf|write|plan|schedule|toon|show|check|update|forward|"
    r"reply|antwoord|beantwoord|download|archiveer|archive)\b",
    re.IGNORECASE,
)
_COMPOUND_CONJUNCTION_SPLIT_RE = re.compile(
    r"\s+(?:en|and|ook|also)\s+(?=(?:stuur|send|maak|create|zoek|search|find|verwijder|delete|open|"
    r"lees|read|schrijf|write|plan|schedule|toon|show|check|update|forward|"
    r"reply|antwoord|beantwoord|download|archiveer|archive)\b)",
    re.IGNORECASE,
)
_NUMBERED_COMPOUND_ITEM_RE = re.compile(
    r"(?:^|[:;]\s*|\n\s*|\s+(?:en|and)\s+)(\d{1,2})\.\s+",
    re.IGNORECASE,
)


def _rewrite_numbered_compound_item(prefix: str, item: str) -> str:
    cleaned_item = str(item or "").strip(" ,;:\t\r\n")
    if not cleaned_item:
        return ""
    if _ACTION_VERB_RE.search(cleaned_item):
        return cleaned_item

    prefix_text = str(prefix or "").strip()
    prefix_lower = prefix_text.lower()
    is_dutch = bool(re.search(r"\b(kun|jij|je|afspraak|agenda|vandaag|morgen|vanaf|tot|locatie)\b", prefix_lower))

    if _CALENDAR_MARKERS_RE.search(prefix_text):
        if re.search(r"\b(update|wijzig|verplaats|move|reschedule|delete|remove|verwijder|cancel|annuleer)\b", prefix_lower):
            return ("wijzig afspraak " if is_dutch else "update calendar event ") + cleaned_item
        if re.search(r"\b(maak|create|plan|schedule|add|zet|zetten|voeg\s+toe|book|set\s+up)\b", prefix_lower):
            return ("maak afspraak " if is_dutch else "schedule calendar event ") + cleaned_item

    return cleaned_item


def _split_numbered_compound_instructions(text: str) -> list[str]:
    value = str(text or "").strip()
    if not value:
        return [value]

    matches = list(_NUMBERED_COMPOUND_ITEM_RE.finditer(value))
    if len(matches) < 2:
        return [value]

    prefix = value[:matches[0].start()].strip(" \t\r\n:;")
    parts: list[str] = []
    for index, match in enumerate(matches):
        start = match.end()
        end = matches[index + 1].start() if index + 1 < len(matches) else len(value)
        item = value[start:end].strip(" \t\r\n,;")
        rewritten = _rewrite_numbered_compound_item(prefix, item)
        if rewritten:
            parts.append(rewritten)

    return parts if len(parts) > 1 else [value]


def _should_keep_email_send_body_as_single_task(text: str, parts: list[str]) -> bool:
    valid = [str(part or "").strip() for part in (parts or []) if str(part or "").strip()]
    if len(valid) != 2:
        return False
    first, second = valid
    if not re.match(r"^(?:stuur|send)\b", first, re.IGNORECASE):
        return False
    if not _EMAIL_MARKERS_RE.search(first):
        return False
    if not re.match(r"^(?:schrijf|write)\b", second, re.IGNORECASE):
        return False
    try:
        intent = parse_email_intent(text)
    except Exception:
        return False
    return (
        str(getattr(intent, "action", "") or "").strip().lower() == "send"
        and bool(str(getattr(intent, "to", "") or "").strip())
    )


def split_compound_instructions(text: str) -> list[str]:
    """Split multi-action instructions into individual sub-tasks.

    Returns a list with 1 element (original) if not compound, or multiple
    sub-tasks if the input contains independent action clauses.
    """
    text = str(text or "").strip()
    if not text:
        return [text]
    numbered_parts = _split_numbered_compound_instructions(text)
    if len(numbered_parts) > 1:
        return numbered_parts
    parts = _COMPOUND_SPLIT_RE.split(text)
    if len(parts) <= 1:
        parts = _COMPOUND_CONJUNCTION_SPLIT_RE.split(text)
    if len(parts) <= 1:
        return [text]
    # Only accept splits where each part starts with an action verb
    valid = [p.strip() for p in parts if p.strip()]
    if len(valid) <= 1:
        return [text]
    if _should_keep_email_send_body_as_single_task(text, valid):
        return [text]
    # Require at least 2 parts to start with an action verb
    verb_count = sum(1 for p in valid if _ACTION_VERB_RE.search(p))
    if verb_count < 2:
        return [text]
    return valid


def resolve_task_turn(user_message: str, session: dict | None = None) -> dict | None:
    text = str(user_message or "").strip()
    if not text:
        return None
    # Copilot briefing goals contain email-related words ("draft", "send",
    # "reply") in their instructional paragraphs.  They must never be
    # classified as email-action tasks.
    if is_copilot_morning_brief_goal(text) or is_copilot_followup_goal(text):
        return None
    prior = _clone_prior_task(session or {})
    prior_task_type = str(prior.get("task_type") or "")
    followup_relation = _infer_followup_relation(text, prior)
    if _WRITE_ACTION_RE.search(text):
        return _resolve_write_task_turn(text, session)
    prior_action_hint = False
    if prior_task_type == "email_action":
        prior_action_hint = bool(_EMAIL_MARKERS_RE.search(text) or _EMAIL_CONTENT_MARKERS_RE.search(text) or _PRONOUN_ENTITY_RE.search(text) or _CORRECTION_RE.search(text))
    elif prior_task_type == "calendar_action":
        prior_action_hint = bool(_CALENDAR_MARKERS_RE.search(text) or _PRONOUN_ENTITY_RE.search(text) or _CORRECTION_RE.search(text))
    if prior_task_type in {"email_action", "calendar_action"} and (followup_relation in {"correction", "constraint_change"} or prior_action_hint):
        followup_write_task = _resolve_write_task_turn(text, session)
        if followup_write_task is not None:
            return followup_write_task
    provider_scope = _detect_provider_scope(text, str(prior.get("provider_scope") or ""))
    time_filter = _detect_time_filter(text) or "none"
    explicit_date_range = _extract_explicit_date_range(text)
    entity = _extract_target_entity(text)

    is_email = bool(_EMAIL_MARKERS_RE.search(text))
    is_calendar = bool(_CALENDAR_MARKERS_RE.search(text))
    is_email, is_calendar = _disambiguate_lookup_domain(
        text,
        prior_task_type=prior_task_type,
        followup_relation=followup_relation,
        is_email=is_email,
        is_calendar=is_calendar,
    )
    is_search = _is_search_request(text, prior_task_type, followup_relation)
    provider_only_correction = (
        followup_relation == "correction"
        and (_PROVIDER_OUTLOOK_RE.search(text) or _PROVIDER_GMAIL_RE.search(text))
        and not _EMAIL_MARKERS_RE.search(text)
        and not is_calendar
    )
    if provider_only_correction and prior_task_type == "calendar_lookup":
        is_email = False
        is_calendar = True
    elif is_calendar:
        is_email = False
    if not is_email and not is_calendar and prior_task_type == "email_lookup" and followup_relation == "constraint_change":
        is_email = True
    if not is_email and not is_calendar and prior_task_type == "calendar_lookup" and followup_relation == "constraint_change":
        is_calendar = True
    # Only inherit prior domain for weaker followup signals when the
    # user's text still hints at the same domain (pronouns like "reply
    # to that" count, but an unrelated question should NOT stick).
    _has_email_hint = bool(_EMAIL_MARKERS_RE.search(text) or _EMAIL_CONTENT_MARKERS_RE.search(text) or _PRONOUN_ENTITY_RE.search(text))
    _has_cal_hint = bool(_CALENDAR_MARKERS_RE.search(text) or _PRONOUN_ENTITY_RE.search(text))
    if not is_email and not is_calendar and prior_task_type == "email_lookup" and followup_relation != "new_task" and _has_email_hint:
        is_email = True
    if not is_calendar and not is_email and prior_task_type == "calendar_lookup" and followup_relation != "new_task" and _has_cal_hint:
        is_calendar = True
        if not _EMAIL_MARKERS_RE.search(text):
            is_email = False
    if (is_email or is_calendar) and prior_task_type != "search_lookup":
        is_search = False

    if not is_email and not is_calendar and not is_search:
        return None

    task = copy.deepcopy(prior) if followup_relation != "new_task" and prior else {}
    if is_search:
        search_source = _detect_search_source(text, str(task.get("source_scope") or ""))
        search_query = _extract_search_query(text)
        if not search_query and prior_task_type == "search_lookup":
            search_query = str(task.get("search_query") or "")
        if not search_query:
            return None
        task.update({
            "task_type": "search_lookup",
            "operation": "search",
            "source_scope": search_source,
            "search_query": search_query,
            "requires_live_data": True,
            "can_answer_from_context": False,
            "followup_relation": followup_relation,
        })
    elif is_email:
        task["task_type"] = "email_lookup"
        folder_scope = _detect_folder(text, str(task.get("folder_scope") or "Inbox"))
        result_shape = "count" if _COUNT_MARKERS_RE.search(text) else "list"
        operation = "count_sent" if result_shape == "count" and folder_scope == "Sent" else result_shape
        if result_shape == "list":
            operation = "find_sent" if folder_scope == "Sent" else "find_received"
        task.update({
            "operation": operation,
            "provider_scope": provider_scope,
            "folder_scope": folder_scope,
            "time_filter": time_filter if time_filter != "none" else str(task.get("time_filter") or "none"),
            "result_shape": result_shape,
            "requires_live_data": True,
            "can_answer_from_context": False,
            "followup_relation": followup_relation,
        })
        if explicit_date_range:
            task["time_filter"] = "custom_range"
            task["since"] = explicit_date_range["since"]
            task["until"] = explicit_date_range["until"]
            task["time_label"] = explicit_date_range["time_label"]
        elif time_filter != "none":
            task.pop("since", None)
            task.pop("until", None)
            task.pop("time_label", None)
        if entity.get("text") and entity.get("kind") != "reference":
            task["target_entity"] = entity
        elif entity.get("kind") == "reference" and prior.get("target_entity"):
            task["target_entity"] = copy.deepcopy(prior.get("target_entity"))
        elif prior.get("target_entity") and followup_relation != "new_task":
            task["target_entity"] = copy.deepcopy(prior.get("target_entity"))
        task["target_reference"] = _extract_lookup_reference(
            text,
            domain="email",
            entity=task.get("target_entity") or {},
            prior_reference=prior.get("target_reference") or {},
        )
    else:
        calendar_days = _detect_calendar_days(text)
        if followup_relation != "new_task" and not _has_calendar_horizon_hint(text) and not explicit_date_range:
            calendar_days = int(task.get("days_ahead") or calendar_days or 7)
        calendar_time_filter = time_filter if time_filter != "none" else str(task.get("time_filter") or "none")
        if _has_calendar_horizon_hint(text) and time_filter == "none":
            calendar_time_filter = "none"
        task.update({
            "task_type": "calendar_lookup",
            "operation": "calendar_read",
            "provider_scope": provider_scope,
            "time_filter": calendar_time_filter,
            "days_ahead": calendar_days,
            "requires_live_data": True,
            "can_answer_from_context": False,
            "followup_relation": followup_relation,
        })
        if explicit_date_range:
            task["time_filter"] = "custom_range"
            task["since"] = explicit_date_range["since"]
            task["until"] = explicit_date_range["until"]
            task["time_label"] = explicit_date_range["time_label"]
            task["days_ahead"] = 1
        elif time_filter != "none" or _has_calendar_horizon_hint(text):
            task.pop("since", None)
            task.pop("until", None)
            task.pop("time_label", None)
        task["target_reference"] = _extract_lookup_reference(
            text,
            domain="calendar",
            prior_reference=prior.get("target_reference") or {},
        )

    task["source_text"] = text
    task["confidence"] = 0.9 if followup_relation == "new_task" else 0.72
    frame = build_intent_frame(task)
    if frame is not None:
        task = task_state_from_intent_frame(frame, base_task_state=task)
        task["intent_frame"] = frame.to_dict()
    return task


def _resolve_write_task_turn(user_message: str, session: dict | None = None) -> dict | None:
    text = str(user_message or "").strip()
    if not text:
        return None
    prior = _clone_prior_task(session or {})
    prior_task_type = str(prior.get("task_type") or "")
    followup_relation = _infer_followup_relation(text, prior)

    is_email = bool(_EMAIL_MARKERS_RE.search(text))
    is_calendar = bool(_CALENDAR_MARKERS_RE.search(text))
    # When both match (e.g. "verwijder teamoverleg uit mijn outlook agenda"),
    # prefer calendar — "agenda/calendar" is a stronger domain signal than
    # a provider name like "outlook" that also appears in _EMAIL_MARKERS_RE.
    if is_email and is_calendar:
        is_email = False
    if not is_email and not is_calendar:
        if prior_task_type == "email_action":
            is_email = True
        elif prior_task_type == "calendar_action":
            is_calendar = True

    if is_email:
        intent = parse_email_intent(text)
        prior_email_intent = _prior_email_action_intent(prior) if prior_task_type == "email_action" else None
        if prior_email_intent and (str(intent.action or "").strip() or _email_intent_has_material(intent)):
            if not str(intent.action or "").strip():
                intent.action = prior_email_intent.action
            intent = merge_email_intents(intent, prior_email_intent)
        if not intent.action:
            return None
        frame = email_intent_to_frame(intent, source_text=text)
        frame.followup_relation = followup_relation
        frame.requires_live_data = True
        frame.can_answer_from_context = False
        frame.operation = frame.action or "send"
        task = task_state_from_intent_frame(frame, base_task_state=copy.deepcopy(prior) if followup_relation != "new_task" and prior else {})
        task["task_type"] = "email_action"
        task["operation"] = frame.action or "send"
        task["requires_live_data"] = True
        task["can_answer_from_context"] = False
        task["followup_relation"] = followup_relation
        task["intent_frame"] = frame.to_dict()
        return task

    if is_calendar:
        intent = parse_appointment_intent(text)
        prior_calendar_intent = _prior_calendar_action_intent(prior) if prior_task_type == "calendar_action" else None
        if prior_calendar_intent and (str(intent.action or "").strip() or _appointment_intent_has_material(intent)):
            if not str(intent.action or "").strip():
                intent.action = prior_calendar_intent.action
            intent = merge_appointment_intents(intent, prior_calendar_intent)
        if not intent.action:
            return None
        frame = appointment_intent_to_frame(intent, source_text=text)
        frame.followup_relation = followup_relation
        frame.requires_live_data = True
        frame.can_answer_from_context = False
        frame.operation = frame.action or "create"
        task = task_state_from_intent_frame(frame, base_task_state=copy.deepcopy(prior) if followup_relation != "new_task" and prior else {})
        task["task_type"] = "calendar_action"
        task["operation"] = frame.action or "create"
        task["requires_live_data"] = True
        task["can_answer_from_context"] = False
        task["followup_relation"] = followup_relation
        task["intent_frame"] = frame.to_dict()
        return task

    return None


def task_state_to_direct_intent(task_state: dict | None) -> dict | None:
    return intent_frame_to_direct_intent(build_intent_frame(task_state))


def task_state_display_goal(task_state: dict | None) -> str:
    return intent_frame_display_goal(build_intent_frame(task_state))