"""
Email workflow helpers — record matching, followup, action, intent extraction.

Extracted from main.py to keep individual modules manageable.
"""

import json
import re
import unicodedata
from html import unescape

from backend.action_intents import ReferenceSpec, ordinal_to_index
from backend.config import OUTLOOK_SUPPORT
from backend.copilot_briefing import is_copilot_followup_goal, is_copilot_morning_brief_goal
from backend.direct_accelerators import _detect_email_source_scope
from backend.email_intents import (
    EmailIntent,
    detect_email_action,
    email_intent_from_llm_payload,
    email_intent_to_send_payload,
    email_intent_to_update_payload,
    merge_email_intents,
    parse_email_intent,
)
from backend.intent_frames import (
    build_intent_frame,
    email_intent_from_frame,
    email_intent_to_frame,
    intent_frame_from_payload,
)
from backend.models import _call_text_model_candidates, _is_llm_error
from backend.parsing import _parse_model_json
from backend.response_composers import (
    _looks_dutch,
    compose_direct_results,
    compose_tool_response,
    extract_email_lookup_records,
)
from backend.session_context import _session_tool_trace


# ---------------------------------------------------------------------------
# Shared text helpers (also used by calendar workflow + main)
# ---------------------------------------------------------------------------

def _normalize_match_text(text: str) -> str:
    value = unescape(str(text or "")).lower()
    value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
    value = re.sub(r"[^a-z0-9@]+", " ", value)
    return re.sub(r"\s+", " ", value).strip()


_NUMBER_WORDS = {
    "een": 1, "one": 1,
    "twee": 2, "two": 2,
    "drie": 3, "three": 3,
    "vier": 4, "four": 4,
    "vijf": 5, "five": 5,
}


# ---------------------------------------------------------------------------
# Side-effect detection  (used by email & calendar action guards)
# ---------------------------------------------------------------------------

def _goal_requests_side_effect(goal_text: str) -> bool:
    if is_copilot_morning_brief_goal(goal_text) or is_copilot_followup_goal(goal_text):
        return False
    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*|versturen|stuur\w*|sturen|antwoord\w*|doorstuur\w*|doorstur\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(goal_text or ""),
        re.IGNORECASE,
    ))


# ---------------------------------------------------------------------------
# Email regex patterns
# ---------------------------------------------------------------------------

_EMAIL_LOOKUP_TOOL_NAMES = {
    "gmail_get_inbox",
    "gmail_search_emails",
    "outlook_get_inbox",
    "outlook_search_emails",
}
_EMAIL_FOLLOWUP_INTENT_RE = re.compile(
    r"\b(waar gaat|waar ging|uitleg(?:gen)?|leg uit|samenvat(?:ten|ting)?|summary|what is|what's|explain|tell me about)\b",
    re.IGNORECASE,
)
_SUMMARY_REQUEST_RE = re.compile(
    r"\b(vat|samenvat(?:ten|ting)?|summary|waar gaat|waar gaan|waarover|what(?:\s+is|\'s)?\s+.*about|overzicht)\b",
    re.IGNORECASE,
)
_EMAIL_FOLLOWUP_LLM_RE = re.compile(
    r"\b(advies|adviseer|aanpak|actie(?:stappen|plan)?|wat moet ik|wat raad je|hoe reageer|hoe antwoord|"
    r"hoe kan ik antwoorden|conceptantwoord|antwoordvoorstel|stel .*antwoord|schrijf .*antwoord|"
    r"reply advice|what should i do|what do you suggest|how should i respond|how should i reply|"
    r"draft (?:a )?reply|suggest(?:ed)? reply|recommended response|next step|next steps|action plan|"
    r"welke tips|wat staat er|what does it say|what tips|waar gaat het over|wat is de inhoud|"
    r"geef .*samenvatting|summarize|summarise|explain|uitleggen|vertel meer|tell me more|"
    r"wat bedoelen ze|what do they mean|wat betekent|what does .* mean)\b",
    re.IGNORECASE,
)
_EMAIL_READ_REQUEST_RE = re.compile(
    r"\b(open|openen|toon|laat\s+zien|lees|read|show|view|get|haal\s+op|fetch)\b",
    re.IGNORECASE,
)
_EMAIL_ATTACHMENT_REQUEST_RE = re.compile(
    r"\b(bijlage|bijlagen|attachment|attachments|attached|bestand|bestanden|document|documenten|pdf|excel|spreadsheet|sheet|docx?|pptx?)\b",
    re.IGNORECASE,
)
_EMAIL_REFERENCE_RE = re.compile(r"\b(?:email|emails|e-mail|e-mails|mail|mails|bericht|berichten)\b", re.IGNORECASE)
_EMAIL_SELECTED_REFERENCE_RE = re.compile(
    r"\b(?:deze|die|dit|that|this|it|zelfde|same)\b",
    re.IGNORECASE,
)
_EMAIL_OFFER_ALREADY_RE = re.compile(
    r"\b(doorvragen|open\s+(?:deze\s+|die\s+|de\s+)?(?:e-?mail|mail)|"
    r"laat\s+.*(?:e-?mail|mail)|show\s+.*(?:email|mail)|"
    r"ik\s+heb\s+.*onthouden|i\s+have\s+.*remembered)\b",
    re.IGNORECASE,
)
_EMAIL_ORDINAL_RE = re.compile(
    r"\b(?:nummer|nr\.?|number)\s*(\d{1,2})\b|\b(?:de|the)\s+(eerste|tweede|derde|vierde|vijfde|first|second|third|fourth|fifth)\b",
    re.IGNORECASE,
)
_EMAIL_INDEX_IN_CONTEXT_RE = re.compile(
    r"\b(?:open|openen|lees|read|toon|show|view|get|haal\s+op|fetch)\s+(?:de\s+|het\s+)?(?:e-?mail|mail|bericht)\s*(?:nummer|nr\.?|number)?\s*(\d{1,2})\b"
    r"|\b(?:e-?mail|mail|bericht)\s*(?:nummer|nr\.?|number)?\s*(\d{1,2})\b",
    re.IGNORECASE,
)
_EMAIL_MATCH_STOPWORDS = {
    "de", "het", "een", "en", "of", "van", "voor", "met", "naar", "over", "die", "dat", "dit",
    "waar", "gaat", "ging", "kun", "kunt", "kunnen", "je", "jij", "u", "mail", "email", "e",
    "bericht", "berichten", "uitleg", "uitleggen", "leg", "uit", "what", "about", "tell", "me",
    "the", "this", "that", "from", "subject", "onderwerp",
}


# ---------------------------------------------------------------------------
# Token matching
# ---------------------------------------------------------------------------

def _match_tokens(text: str) -> set[str]:
    return {
        token for token in _normalize_match_text(text).split()
        if len(token) >= 3 and token not in _EMAIL_MATCH_STOPWORDS
    }


# ---------------------------------------------------------------------------
# Email record helpers
# ---------------------------------------------------------------------------

_EMAIL_RECORD_CAP = 200

def _dedupe_email_records(records: list[dict]) -> list[dict]:
    deduped: list[dict] = []
    seen: set[tuple[str, str, str, str, str]] = set()
    for record in records:
        key = (
            str(record.get("provider") or "").strip().lower(),
            str(record.get("message_id") or "").strip().lower(),
            str(record.get("subject", "") or "").strip().lower(),
            str(record.get("from", "") or "").strip().lower(),
            str(record.get("to", "") or "").strip().lower(),
        )
        if key in seen:
            continue
        seen.add(key)
        deduped.append(record)
    return deduped


def _email_records_from_tool_trace(trace: list[dict]) -> list[dict]:
    records: list[dict] = []
    for item in trace:
        tool_name = str(item.get("tool_name") or "").strip()
        if tool_name not in _EMAIL_LOOKUP_TOOL_NAMES:
            continue
        tool_details = item.get("tool_details") if isinstance(item.get("tool_details"), dict) else {}
        detail_records = tool_details.get("records") if isinstance(tool_details.get("records"), list) else []
        source_records = detail_records or extract_email_lookup_records(str(item.get("tool_result") or ""))
        for record in source_records:
            if not isinstance(record, dict):
                continue
            record = dict(record)
            if tool_name.startswith("gmail"):
                record.setdefault("provider", "gmail")
            elif tool_name.startswith("outlook"):
                record.setdefault("provider", "outlook")
            records.append(record)
    return _dedupe_email_records(records)[:_EMAIL_RECORD_CAP]


def _session_email_records(session: dict | None) -> list[dict]:
    source = session or {}
    stored = source.get("last_email_results") or []
    if isinstance(stored, list):
        cleaned = [item for item in stored if isinstance(item, dict)]
        if cleaned:
            return cleaned
    return _email_records_from_tool_trace(_session_tool_trace(source))


# ---------------------------------------------------------------------------
# Ordinal / contextual index
# ---------------------------------------------------------------------------

def _email_ordinal_index(message: str) -> int | None:
    match = _EMAIL_ORDINAL_RE.search(str(message or ""))
    if not match:
        return None
    if match.group(1):
        try:
            return max(0, int(match.group(1)) - 1)
        except ValueError:
            return None
    word = str(match.group(2) or "").lower()
    mapping = {
        "eerste": 0, "first": 0,
        "tweede": 1, "second": 1,
        "derde": 2, "third": 2,
        "vierde": 3, "fourth": 3,
        "vijfde": 4, "fifth": 4,
    }
    return mapping.get(word)


def _email_contextual_index(message: str) -> int | None:
    match = _EMAIL_INDEX_IN_CONTEXT_RE.search(str(message or ""))
    if not match:
        return None
    value = str(match.group(1) or match.group(2) or "").strip()
    if not value:
        return None
    try:
        return max(0, int(value) - 1)
    except ValueError:
        return None


# ---------------------------------------------------------------------------
# Scoring & selection
# ---------------------------------------------------------------------------

def _score_email_record_match(record: dict, message: str) -> int:
    message_norm = _normalize_match_text(message)
    subject = str(record.get("subject") or "").strip()
    sender = str(record.get("from") or record.get("to") or "").strip()
    preview = str(record.get("preview") or "").strip()
    subject_norm = _normalize_match_text(subject)
    sender_norm = _normalize_match_text(sender)
    preview_norm = _normalize_match_text(preview)

    score = 0
    if subject_norm:
        if subject_norm in message_norm:
            score += 120 + min(len(subject_norm), 40)
        elif message_norm in subject_norm and len(message_norm) >= 8:
            score += 80
    if sender_norm and sender_norm in message_norm:
        score += 60
    message_tokens = _match_tokens(message)
    overlap = len(message_tokens & _match_tokens(subject))
    if overlap:
        score += overlap * 18
    if preview_norm and any(token in preview_norm for token in sorted(message_tokens, key=len, reverse=True)[:4]):
        score += 12
    return score


def _select_email_record(records: list[dict], message: str) -> dict | None:
    if not records:
        return None

    ordinal = _email_contextual_index(message)
    if ordinal is None:
        ordinal = _email_ordinal_index(message)
    if ordinal is not None and 0 <= ordinal < len(records):
        return records[ordinal]

    scored = [(_score_email_record_match(record, message), record) for record in records]
    scored.sort(key=lambda item: item[0], reverse=True)
    if not scored or scored[0][0] < 50:
        return None
    if len(scored) > 1 and scored[1][0] >= scored[0][0] - 8:
        return None
    return scored[0][1]


# ---------------------------------------------------------------------------
# Followup helpers
# ---------------------------------------------------------------------------


def _looks_dutch_text(text: str) -> bool:
    return _looks_dutch(text)


def _compose_email_record_followup(record: dict, message: str) -> str | None:
    if not _EMAIL_FOLLOWUP_INTENT_RE.search(message) and not _EMAIL_REFERENCE_RE.search(message):
        return None

    dutch = _looks_dutch_text(message)
    subject = str(record.get("subject") or "deze e-mail").strip()
    sender = str(record.get("from") or record.get("to") or "unknown sender").strip()
    preview = str(record.get("preview") or "").strip()
    date_value = str(record.get("date") or "").strip()

    if not preview:
        if dutch:
            reply = f"Ik kan deze e-mail wel herkennen als '{subject}' van {sender}, maar ik heb in de huidige sessie geen samenvatting van de inhoud opgeslagen."
            if date_value:
                reply += f" Datum: {date_value}."
            return reply
        reply = f"I can identify this email as '{subject}' from {sender}, but I don't have a stored content summary for it in the current session."
        if date_value:
            reply += f" Date: {date_value}."
        return reply

    provider_hint = ""
    if "overheid" in sender.lower() or "buurt" in subject.lower():
        provider_hint = "Het lijkt een melding van Overheid.nl over je buurt. " if dutch else "It looks like a government neighborhood notice. "

    if dutch:
        reply = f"De e-mail '{subject}' van {sender} gaat hierover: {provider_hint}In de samenvatting staat: {preview}"
        if date_value:
            reply += f" Datum: {date_value}."
        return reply

    reply = f"The email '{subject}' from {sender} is about this: {provider_hint}The stored summary says: {preview}"
    if date_value:
        reply += f" Date: {date_value}."
    return reply


def _email_followup_should_use_llm(message: str) -> bool:
    text = str(message or "").strip()
    if not text or not _EMAIL_REFERENCE_RE.search(text):
        return False
    if _email_attachment_focus_requested(text):
        return True
    if _EMAIL_READ_REQUEST_RE.search(text):
        return True
    return bool(_EMAIL_FOLLOWUP_LLM_RE.search(text))


def _email_attachment_focus_requested(message: str) -> bool:
    text = str(message or "").strip()
    if not text:
        return False
    return bool(_EMAIL_ATTACHMENT_REQUEST_RE.search(text))


def _default_selected_email_record(session: dict | None) -> dict | None:
    source = session if isinstance(session, dict) else {}
    selected = source.get("last_selected_email") if isinstance(source.get("last_selected_email"), dict) else None
    if selected:
        return dict(selected)
    records = _session_email_records(session)
    if len(records) == 1:
        if isinstance(session, dict):
            session["last_selected_email"] = dict(records[0])
        return dict(records[0])
    return None


async def _classify_selected_email_followup_decision(
    session: dict | None,
    user_message: str,
    model_candidates: list[str],
) -> dict:
    text = str(user_message or "").strip()
    if not text:
        return {
            "targets_selected_email": False,
            "needs_full_email_read": False,
            "needs_attachment_analysis": False,
            "confidence": 0.0,
        }

    selected = _default_selected_email_record(session)
    if not selected or not model_candidates:
        return {
            "targets_selected_email": False,
            "needs_full_email_read": False,
            "needs_attachment_analysis": False,
            "confidence": 0.0,
        }

    if re.search(r"https?://|www\.|[a-zA-Z]:\\|(?:\./|\.\\|/)[^\s]+\.(?:pdf|docx?|xlsx?|pptx?|txt|csv|json|xml|html?|md)\b", text, re.IGNORECASE):
        return {
            "targets_selected_email": False,
            "needs_full_email_read": False,
            "needs_attachment_analysis": False,
            "confidence": 0.0,
        }

    subject = str(selected.get("subject") or "").strip()
    sender = str(selected.get("from") or "").strip()
    preview = str(selected.get("preview") or "").strip()
    provider = str(selected.get("provider") or "").strip()
    date_value = str(selected.get("date") or "").strip()
    history = []
    if isinstance(session, dict):
        for item in (session.get("chat_history") or [])[-6:]:
            role = "User" if item.get("role") == "user" else "Assistant"
            content = str(item.get("content") or "").strip()
            if content:
                history.append(f"{role}: {content[:500]}")
    prompt = (
        "Decide whether the user's latest follow-up refers to the currently selected email from the conversation. "
        "Infer meaning across paraphrases and languages. Return ONLY JSON.\n"
        "Use this schema exactly:\n"
        "{\n"
        '  "targets_selected_email": true|false,\n'
        '  "needs_full_email_read": true|false,\n'
        '  "needs_attachment_analysis": true|false,\n'
        '  "confidence": 0.0\n'
        "}\n"
        "Rules:\n"
        "- targets_selected_email=true only if the latest message most naturally refers to the selected email or one of its attached documents/files.\n"
        "- needs_full_email_read=true if answering correctly requires opening the exact email again with live data/tool access instead of relying only on the stored summary.\n"
        "- needs_attachment_analysis=true if the user is asking about the content of an attached file/document rather than just the email body.\n"
        "- Return false for unrelated local files, URLs, workspace documents, code questions, or a new topic.\n"
        "- If needs_attachment_analysis=true, then needs_full_email_read should also be true.\n\n"
        f"Selected email subject: {subject or '(none)'}\n"
        f"Selected email sender: {sender or '(none)'}\n"
        f"Selected email provider: {provider or '(none)'}\n"
        f"Selected email date: {date_value or '(none)'}\n"
        f"Stored summary: {preview or '(none)'}\n\n"
        f"Recent chat:\n{chr(10).join(history) or '(none)'}\n\n"
        f"Latest user message: {text}\n"
    )
    try:
        raw_result, _used_model = await _call_text_model_candidates(
            prompt,
            model_candidates,
            system="You are a strict follow-up classifier. Output ONLY valid JSON.",
            num_ctx=8192,
            thinking=False,
            per_model_timeout=12.0,
        )
        if _is_llm_error(raw_result):
            raise ValueError(str(raw_result or "classifier error"))
        parsed = _parse_model_json(raw_result, expect="object")
        if not isinstance(parsed, dict):
            raise ValueError("classifier returned non-object")
        targets_selected_email = bool(parsed.get("targets_selected_email"))
        needs_attachment_analysis = bool(parsed.get("needs_attachment_analysis"))
        needs_full_email_read = bool(parsed.get("needs_full_email_read")) or needs_attachment_analysis
        confidence = float(parsed.get("confidence") or 0.0)
        return {
            "targets_selected_email": targets_selected_email,
            "needs_full_email_read": needs_full_email_read,
            "needs_attachment_analysis": needs_attachment_analysis,
            "confidence": confidence,
        }
    except Exception:
        return {
            "targets_selected_email": False,
            "needs_full_email_read": False,
            "needs_attachment_analysis": False,
            "confidence": 0.0,
        }


def _build_selected_email_followup_context(session: dict | None, user_message: str) -> str:
    message = str(user_message or "").strip()
    if not message or not _EMAIL_REFERENCE_RE.search(message):
        return ""

    record = _selected_or_matched_email_record(session, message)
    if not record:
        return ""

    subject = str(record.get("subject") or "").strip() or "(no subject)"
    sender = str(record.get("from") or "").strip()
    recipient = str(record.get("to") or "").strip()
    preview = str(record.get("preview") or "").strip()
    provider = str(record.get("provider") or "mail").strip()
    folder = str(record.get("folder") or "").strip()
    date_value = str(record.get("date") or "").strip()
    message_id = str(record.get("message_id") or "").strip()

    lines = [
        "[SELECTED EMAIL CONTEXT]",
        f"Subject: {subject}",
    ]
    if sender:
        lines.append(f"From: {sender}")
    if recipient:
        lines.append(f"To: {recipient}")
    if date_value:
        lines.append(f"Date: {date_value}")
    if provider:
        lines.append(f"Provider: {provider}")
    if folder:
        lines.append(f"Folder: {folder}")
    if message_id:
        lines.append(f"Message ID: {message_id}")
    if preview:
        lines.append(f"Stored summary: {preview}")
    if message_id and _email_attachment_focus_requested(message):
        attachment_tool = "outlook_get_email" if provider == "outlook" else ("gmail_get_email" if provider == "gmail" else "the email read tool")
        lines.extend([
            "",
            "If the user asks about attachment contents, read the selected email with attachment extraction enabled.",
            f"Call {attachment_tool} for this exact Message ID with include_attachments: true.",
            "That mode extracts and analyzes supported attachment contents from the selected email itself.",
            "Do not guess attachment contents from filenames alone.",
        ])
    lines.extend([
        "",
        "Use this selected email as the main context for the user's follow-up.",
        "If the user asks for advice, recommend concrete next steps grounded in this email.",
        "If the user asks for a reply or response suggestion, draft it directly.",
        "Do not claim you saw the full email body unless the stored summary clearly contains it.",
    ])
    return "\n".join(lines)


def _maybe_answer_email_followup(session: dict | None, user_message: str) -> str | None:
    message = str(user_message or "").strip()
    if not message:
        return None
    if not _EMAIL_REFERENCE_RE.search(message):
        return None
    if _email_followup_should_use_llm(message):
        return None
    record = _selected_or_matched_email_record(session, message)
    if not record:
        return None
    return _compose_email_record_followup(record, message)


def _selected_or_matched_email_record(session: dict | None, message: str) -> dict | None:
    records = _session_email_records(session)
    if not records:
        return None

    source = session if isinstance(session, dict) else {}
    selected = source.get("last_selected_email") if isinstance(source.get("last_selected_email"), dict) else None
    if selected and _EMAIL_SELECTED_REFERENCE_RE.search(str(message or "")):
        return selected
    if len(records) == 1 and _EMAIL_SELECTED_REFERENCE_RE.search(str(message or "")):
        if isinstance(session, dict):
            session["last_selected_email"] = dict(records[0])
        return dict(records[0])

    matched = _select_email_record(records, message)
    if isinstance(session, dict) and matched:
        session["last_selected_email"] = dict(matched)
    if matched:
        return matched
    if selected and _EMAIL_SELECTED_REFERENCE_RE.search(str(message or "")):
        return selected
    return None


# ---------------------------------------------------------------------------
# Email read calls
# ---------------------------------------------------------------------------

def _build_exact_email_read_call(
    session: dict | None,
    message_id: object,
    provider: object,
    *,
    use_outlook: bool,
    gmail_available: bool,
    user_message: str = "",
    force_include_attachments: bool = False,
) -> tuple[str, dict, dict] | None:
    normalized_message_id = str(message_id or "").strip()
    if not normalized_message_id:
        return None

    normalized_provider = str(provider or "").strip().lower()
    matched_record: dict | None = None
    candidate_records: list[dict] = []
    if isinstance(session, dict):
        selected = session.get("last_selected_email")
        if isinstance(selected, dict):
            candidate_records.append(selected)
        candidate_records.extend(_session_email_records(session))

    for item in candidate_records:
        item_mid = str(item.get("message_id") or "").strip()
        item_provider = str(item.get("provider") or "").strip().lower()
        if item_mid != normalized_message_id:
            continue
        if normalized_provider and item_provider and item_provider != normalized_provider:
            continue
        matched_record = dict(item)
        if item_provider:
            normalized_provider = item_provider
        break

    if normalized_provider not in {"gmail", "outlook"} and matched_record:
        matched_provider = str(matched_record.get("provider") or "").strip().lower()
        if matched_provider in {"gmail", "outlook"}:
            normalized_provider = matched_provider

    if normalized_provider not in {"gmail", "outlook"}:
        if use_outlook and not gmail_available:
            normalized_provider = "outlook"
        elif gmail_available and not use_outlook:
            normalized_provider = "gmail"

    if normalized_provider == "outlook" and not use_outlook:
        return None
    if normalized_provider == "gmail" and not gmail_available:
        return None

    if normalized_provider == "outlook":
        tool_name = "outlook_get_email"
    elif normalized_provider == "gmail":
        tool_name = "gmail_get_email"
    else:
        return None

    selected_record = dict(matched_record or {})
    selected_record["message_id"] = normalized_message_id
    selected_record["provider"] = normalized_provider
    if isinstance(session, dict):
        session["last_selected_email"] = dict(selected_record)

    tool_args = {"message_id": normalized_message_id}
    if force_include_attachments or _email_attachment_focus_requested(user_message):
        tool_args["include_attachments"] = True
    return tool_name, tool_args, dict(selected_record)


def _build_email_followup_read_call(
    session: dict | None,
    user_message: str,
    *,
    use_outlook: bool,
    gmail_available: bool,
) -> tuple[str, dict, dict] | None:
    message = str(user_message or "").strip()
    exact_selection = session.get("_pending_exact_email_read") if isinstance(session, dict) else None
    if isinstance(exact_selection, dict):
        exact_call = _build_exact_email_read_call(
            session,
            exact_selection.get("message_id"),
            exact_selection.get("provider"),
            use_outlook=use_outlook,
            gmail_available=gmail_available,
            user_message=message,
            force_include_attachments=bool(exact_selection.get("include_attachments")),
        )
        session.pop("_pending_exact_email_read", None)
        if exact_call:
            return exact_call

    if not message or not _EMAIL_REFERENCE_RE.search(message):
        return None
    if not _EMAIL_READ_REQUEST_RE.search(message):
        return None

    record = _selected_or_matched_email_record(session, message)
    if not record:
        return None
    message_id = str(record.get("message_id") or "").strip()
    if not message_id:
        return None

    provider = str(record.get("provider") or "").strip().lower()
    lower_message = message.lower()
    if provider not in {"gmail", "outlook"}:
        if "gmail" in lower_message and gmail_available:
            provider = "gmail"
        elif "outlook" in lower_message and use_outlook:
            provider = "outlook"
        elif use_outlook and not gmail_available:
            provider = "outlook"
        elif gmail_available and not use_outlook:
            provider = "gmail"

    if provider == "outlook" and not use_outlook:
        return None
    if provider == "gmail" and not gmail_available:
        return None

    if provider == "outlook":
        tool_name = "outlook_get_email"
    elif provider == "gmail":
        tool_name = "gmail_get_email"
    else:
        return None

    if isinstance(session, dict):
        session["last_selected_email"] = dict(record)
    tool_args = {"message_id": message_id}
    if _email_attachment_focus_requested(message):
        tool_args["include_attachments"] = True
    return tool_name, tool_args, dict(record)


def _extract_email_body_preview(read_result: str, max_chars: int = 400) -> str:
    text = str(read_result or "").strip()
    if not text:
        return ""
    if text.startswith((
        "Outlook get-email error:",
        "Gmail get-email error:",
        "Email not found",
        "No message ID provided.",
    )):
        return ""

    parts = re.split(r"\n\s*\n", text, maxsplit=1)
    body = parts[1] if len(parts) > 1 else text
    body = re.sub(r"\s+", " ", body).strip()
    if not body:
        return ""
    return body[:max_chars]


def _update_selected_email_record_from_read_result(session: dict | None, selected_record: dict, read_result: str) -> None:
    if not isinstance(session, dict) or not isinstance(selected_record, dict):
        return

    message_id = str(selected_record.get("message_id") or "").strip()
    provider = str(selected_record.get("provider") or "").strip().lower()
    if not message_id:
        return

    body_preview = _extract_email_body_preview(read_result)
    if not body_preview:
        return

    updated = dict(selected_record)
    updated["preview"] = body_preview
    updated["provider"] = provider or str(updated.get("provider") or "").strip().lower()
    session["last_selected_email"] = updated

    records = _session_email_records(session)
    if not records:
        session["last_email_results"] = [updated]
        return

    merged: list[dict] = []
    replaced = False
    for item in records:
        item_mid = str(item.get("message_id") or "").strip()
        item_provider = str(item.get("provider") or "").strip().lower()
        if item_mid == message_id and (not provider or item_provider == provider):
            merged.append(updated)
            replaced = True
        else:
            merged.append(item)
    if not replaced:
        merged.insert(0, updated)
    session["last_email_results"] = _dedupe_email_records(merged)[:_EMAIL_RECORD_CAP]


def _store_session_email_records(session: dict, trace: list[dict]) -> None:
    records = _email_records_from_tool_trace(trace)
    if records:
        session["last_email_results"] = records
    else:
        session.pop("last_email_results", None)


def _merge_session_email_records(session: dict, records: list[dict]) -> None:
    if not records:
        return
    cleaned = [item for item in records if isinstance(item, dict)]
    if not cleaned:
        return
    existing = _session_email_records(session)
    session["last_email_results"] = _dedupe_email_records(cleaned + existing)[:_EMAIL_RECORD_CAP]


def _email_followup_offer(records: list[dict], *, dutch: bool) -> str:
    if not records:
        return ""
    if dutch:
        return (
            "Ik heb de gevonden e-mails onthouden. "
            "Je kunt nu doorvragen, bijvoorbeeld: 'open e-mail 2', "
            "'laat de e-mail van die afzender zien' of 'waar gaat die over?'."
        )
    return (
        "I remembered the found emails. "
        "You can follow up with: 'open email 2', "
        "'show the email from that sender', or 'what is that one about?'."
    )


def _append_email_followup_offer(text: str, session: dict | None, *, dutch_hint: bool = False) -> str:
    base = str(text or "").strip()
    if not base:
        return base
    if _EMAIL_OFFER_ALREADY_RE.search(base):
        return base
    records = _session_email_records(session)
    if not records:
        return base
    offer = _email_followup_offer(records, dutch=dutch_hint)
    if not offer:
        return base
    return f"{base}\n\n{offer}"


# ---------------------------------------------------------------------------
# Email action helpers
# ---------------------------------------------------------------------------

def _email_action_type(text: str) -> str:
    if not _goal_requests_side_effect(text):
        return ""
    # Avoid matching if the text is clearly about calendar
    from backend.task_understanding import _CALENDAR_MARKERS_RE as _CAL_RE
    if _CAL_RE.search(str(text or "")):
        return ""
    return detect_email_action(text)


def _detect_email_action_provider(text: str, *, use_outlook: bool, gmail_available: bool) -> str:
    scoped_outlook, scoped_gmail, explicit = _detect_email_source_scope(
        text,
        use_outlook=use_outlook,
        gmail_available=gmail_available,
    )
    if scoped_gmail and not scoped_outlook:
        return "gmail"
    if scoped_outlook and not scoped_gmail:
        return "outlook"
    if explicit:
        return ""
    if gmail_available and not use_outlook:
        return "gmail"
    if use_outlook and not gmail_available:
        return "outlook"
    return ""


def _extract_email_subject(text: str) -> str:
    raw = str(text or "").strip()
    if not raw:
        return ""
    patterns = [
        r"\b(?:onderwerp|subject)\s*[:\-]?\s*(.+?)(?=(?:\s+(?:en|ens|and)\s+(?:schrijf|chrijf|write)\b|\s*\b(?:body|tekst|inhoud)\s*[:\-]|[.!?]\s*(?:en|ens|and)?\s*(?:schrijf|chrijf|write)\b|\n|$))",
        r"\b(?:met\s+onderwerp|with\s+subject)\s+(.+?)(?=(?:\s+(?:en|ens|and)\s+(?:schrijf|chrijf|write)\b|\s*\b(?:body|tekst|inhoud)\s*[:\-]|[.!?]\s*(?:en|ens|and)?\s*(?:schrijf|chrijf|write)\b|\n|$))",
    ]
    for pattern in patterns:
        match = re.search(pattern, raw, re.IGNORECASE | re.DOTALL)
        if not match:
            continue
        value = str(match.group(1) or "").strip(" \t\r\n.,;:!?\"'")
        if value:
            return value
    return ""


def _extract_email_body_instruction(text: str) -> str:
    raw = str(text or "").strip()
    if not raw:
        return ""
    literal_match = re.search(r"\b(?:body|tekst|inhoud)\s*[:\-]\s*(.+)$", raw, re.IGNORECASE | re.DOTALL)
    if literal_match:
        return str(literal_match.group(1) or "").strip()
    instruction_match = re.search(r"\b(?:en|ens|and)?\s*(?:schrijf|chrijf|write)\s+(.+)$", raw, re.IGNORECASE | re.DOTALL)
    if instruction_match:
        return str(instruction_match.group(1) or "").strip()
    return ""


def _is_email_forward_request(text: str) -> bool:
    return bool(re.search(r"\b(?:forward|doorstuur\w*|doorstur\w*|stuur\s+door)\b", str(text or ""), re.IGNORECASE))


def _email_forward_subject(subject: str, *, dutch: bool) -> str:
    value = str(subject or "").strip()
    if not value:
        value = "Doorgestuurde e-mail" if dutch else "Forwarded email"
    lowered = value.lower()
    if lowered.startswith(("fw:", "fwd:")):
        return value
    return f"FW: {value}" if dutch else f"Fwd: {value}"


def _build_forward_email_body(record: dict, *, dutch: bool) -> str:
    sender = str(record.get("from") or record.get("to") or "-").strip() or "-"
    subject = str(record.get("subject") or "-").strip() or "-"
    date_value = str(record.get("date") or "-").strip() or "-"
    preview = str(record.get("preview") or "").strip()
    mailbox = str(record.get("mailbox") or "").strip()
    provider = str(record.get("provider") or "").strip()
    source_label = mailbox or ("Gmail" if provider == "gmail" else "Outlook" if provider == "outlook" else "")
    summary_header = "Samenvatting" if dutch else "Summary"
    intro = "Hierbij stuur ik deze e-mail door." if dutch else "Forwarding this email below."
    lines = [
        "Hallo," if dutch else "Hello,",
        "",
        intro,
        "",
        f"Van: {sender}" if dutch else f"From: {sender}",
        f"Datum: {date_value}" if dutch else f"Date: {date_value}",
        f"Onderwerp: {subject}" if dutch else f"Subject: {subject}",
    ]
    if source_label:
        lines.append(f"Bron: {source_label}" if dutch else f"Source: {source_label}")
    if preview:
        lines.extend(["", f"{summary_header}:", preview])
    lines.extend(["", "Met vriendelijke groet," if dutch else "Kind regards,"])
    return "\n".join(lines)


def _resolve_forward_send_provider(record: dict, *, explicit_provider: str, use_outlook: bool, gmail_available: bool) -> str:
    if explicit_provider in {"gmail", "outlook"}:
        return explicit_provider
    record_provider = str(record.get("provider") or "").strip()
    if record_provider == "gmail" and gmail_available:
        return "gmail"
    if record_provider == "outlook" and use_outlook and OUTLOOK_SUPPORT:
        return "outlook"
    if gmail_available and not (use_outlook and OUTLOOK_SUPPORT):
        return "gmail"
    if use_outlook and OUTLOOK_SUPPORT and not gmail_available:
        return "outlook"
    return explicit_provider


def _extract_email_send_request(text: str) -> dict[str, str]:
    return email_intent_to_send_payload(parse_email_intent(text))


def _email_send_action_report(provider: str, payload: dict[str, str], tool_result: str, *, dutch: bool) -> str:
    raw = str(tool_result or "").strip()
    lowered = raw.lower()
    if raw.startswith(("ERROR", "POLICY_DENIED", "HITL_REQUIRED", "TOOL_DISABLED")) or " send error:" in lowered:
        return raw
    if not raw.startswith(("Email sent", "Email sent successfully")):
        return raw
    provider_label = "Gmail" if provider == "gmail" else "Outlook"
    recipient = str(payload.get("to") or "").strip()
    subject = str(payload.get("subject") or "").strip()
    if dutch:
        return f"Ik heb de e-mail via {provider_label} verstuurd naar {recipient} met onderwerp '{subject}'."
    return f"I sent the email via {provider_label} to {recipient} with subject '{subject}'."


def _email_requested_count(text: str, default: int = 1) -> int:
    value = str(text or "")
    digit_match = re.search(r"\b(\d{1,2})\b", value)
    if digit_match:
        try:
            return max(1, min(int(digit_match.group(1)), 10))
        except ValueError:
            pass
    for word, number in _NUMBER_WORDS.items():
        if re.search(rf"\b{re.escape(word)}\b", value, re.IGNORECASE):
            return number
    return default


def _extract_email_selection_query(text: str, action: str) -> str:
    return str(parse_email_intent(text).selection_query or "").strip()


def _email_reference_spec(*, query: str = "", kind: str = "", ordinal: int | None = None, temporal_hint: str = "") -> ReferenceSpec:
    return ReferenceSpec(query=str(query or "").strip(), kind=str(kind or "").strip(), ordinal=ordinal, temporal_hint=str(temporal_hint or "").strip())


def _email_record_matches_temporal_hint(record: dict, temporal_hint: str) -> bool:
    hint = _normalize_match_text(temporal_hint)
    if not hint:
        return True
    date_value = _normalize_match_text(str(record.get("date") or ""))
    return bool(date_value and hint in date_value)


def _rank_email_records(records: list[dict], query: str) -> list[tuple[int, dict]]:
    scored = [(_score_email_record_match(record, query), record) for record in records]
    scored = [item for item in scored if item[0] > 0]
    scored.sort(key=lambda item: item[0], reverse=True)
    return scored


def _select_email_records(
    records: list[dict],
    message: str,
    query: str,
    requested_count: int,
    *,
    reference_kind: str = "",
    reference_ordinal: int | None = None,
    reference_temporal_hint: str = "",
) -> list[dict]:
    if not records:
        return []

    reference = _email_reference_spec(
        query=query,
        kind=reference_kind,
        ordinal=reference_ordinal,
        temporal_hint=reference_temporal_hint,
    )
    filtered_records = list(records)
    if reference.temporal_hint:
        temporal_matches = [record for record in filtered_records if _email_record_matches_temporal_hint(record, reference.temporal_hint)]
        if temporal_matches:
            filtered_records = temporal_matches

    ranked = _rank_email_records(filtered_records, reference.query or query or message)
    candidates = [record for _, record in ranked] if ranked else filtered_records

    ordinal = ordinal_to_index(reference.ordinal, len(candidates))
    if ordinal is None:
        ordinal = _email_ordinal_index(message)
    if ordinal is not None and 0 <= ordinal < len(candidates):
        return [candidates[ordinal]]

    scored = ranked if ranked else []
    if not scored:
        if requested_count == 1 and len(filtered_records) == 1:
            return [filtered_records[0]]
        if requested_count > 1 and len(filtered_records) <= requested_count:
            return filtered_records[:requested_count]
        return []

    if requested_count == 1:
        if len(scored) > 1 and scored[1][0] >= scored[0][0] - 8:
            return [scored[0][1], scored[1][1]]
        return [scored[0][1]]
    return [record for _, record in scored[:requested_count]]


def _extract_email_update_directives(text: str) -> dict[str, str]:
    return email_intent_to_update_payload(parse_email_intent(text))


def _email_action_report(action: str, results: list[tuple[str, str]], *, dutch: bool) -> str:
    if not results:
        return "Ik kon geen e-mailactie uitvoeren." if dutch else "I could not execute any email action."
    report_goal = "kun je deze e-mailactie uitvoeren" if dutch else "email action"
    if len(results) == 1:
        return compose_direct_results(report_goal, results, results[0][1])
    intro_map = {
        "delete": "Ik heb {count} e-mails verwijderd." if dutch else "I deleted {count} emails.",
        "update": "Ik heb {count} conceptmails bijgewerkt." if dutch else "I updated {count} draft emails.",
        "send": "Ik heb {count} e-mails verstuurd." if dutch else "I sent {count} emails.",
    }
    lines = [intro_map.get(action, intro_map["update"]).format(count=len(results))]
    for tool_name, tool_result in results:
        rendered, _ = compose_tool_response(report_goal, report_goal, tool_name, tool_result, tool_result)
        lines.append(rendered.strip())
    return "\n\n".join(lines)


# ---------------------------------------------------------------------------
# Email intent
# ---------------------------------------------------------------------------

def _email_intent_missing_clarification(intent: EmailIntent, *, dutch: bool) -> str:
    missing = set(intent.missing_fields)
    if missing >= {"to", "subject", "body"}:
        return (
            "Ik mis nog de ontvanger, het onderwerp en de inhoud van de e-mail. Geef ze in één keer, bijvoorbeeld: aan naam@bedrijf.nl | onderwerp: ... | tekst: ..."
            if dutch else
            "I still need the recipient, subject, and body of the email. Please give them in one go, for example: to name@company.com | subject: ... | body: ..."
        )
    if missing >= {"subject", "body"}:
        return (
            "Ik mis nog het onderwerp en de inhoud van de e-mail. Geef ze bijvoorbeeld als: onderwerp: ... | tekst: ..."
            if dutch else
            "I still need the email subject and body. Please provide them like: subject: ... | body: ..."
        )
    if missing >= {"to", "subject"}:
        return (
            "Ik mis nog de ontvanger en het onderwerp van de e-mail. Geef ze bijvoorbeeld als: aan naam@bedrijf.nl | onderwerp: ..."
            if dutch else
            "I still need the recipient and subject of the email. Please provide them like: to name@company.com | subject: ..."
        )
    if missing >= {"to", "body"}:
        return (
            "Ik mis nog de ontvanger en de inhoud van de e-mail. Geef ze bijvoorbeeld als: aan naam@bedrijf.nl | tekst: ..."
            if dutch else
            "I still need the recipient and body of the email. Please provide them like: to name@company.com | body: ..."
        )
    if "selection_query" in missing:
        return (
            "Ik mis nog welke e-mail je bedoelt. Noem bijvoorbeeld het onderwerp, de afzender of het volgnummer."
            if dutch else
            "I still need which email you mean. Please mention the subject, sender, or list number."
        )
    if "changes" in missing:
        return (
            "Ik mis nog wat er aan de conceptmail moet veranderen. Noem bijvoorbeeld een nieuw onderwerp, ontvanger of tekst."
            if dutch else
            "I still need what should change in the draft email. For example: a new subject, recipient, or body."
        )
    if "to" in missing:
        return "Ik mis nog het e-mailadres van de ontvanger." if dutch else "I still need the recipient email address."
    if "subject" in missing:
        return "Ik mis nog het onderwerp van de e-mail." if dutch else "I still need the email subject."
    if "body" in missing:
        return "Ik mis nog de inhoud van de e-mail." if dutch else "I still need the email body."
    return (
        "Ik mis nog details om deze e-mailactie veilig uit te voeren."
        if dutch else
        "I still need a few details to safely carry out this email action."
    )


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


def _email_intent_from_task_state(task_state: dict | None) -> EmailIntent | 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(intent.action or "").strip() else None


async def _extract_email_intent_with_fallback(
    message: str,
    *,
    model_candidates: list[str],
    prior_task_state: dict | None = None,
) -> EmailIntent:
    intent = parse_email_intent(message)
    prior_intent = _email_intent_from_task_state(prior_task_state)
    if prior_intent and (str(intent.action or "").strip() or _email_intent_has_material(intent)):
        if not str(intent.action or "").strip():
            intent.action = prior_intent.action
        intent = merge_email_intents(intent, prior_intent)
    if not intent.action:
        return intent
    if intent.confidence >= 0.74 and not intent.missing_fields:
        return intent
    deterministic_frame = email_intent_to_frame(intent, source_text=message)
    prior_frame = email_intent_to_frame(prior_intent, source_text="previous email action") if prior_intent else None
    prompt = (
        "Extract an email action intent as an IntentFrame JSON object only. Do not add prose.\n"
        "Use this schema exactly:\n"
        "{\n"
        '  "domain": "email",\n'
        '  "action": "send|update|delete|",\n'
        '  "provider_scope": "gmail_only|outlook_only|default",\n'
        '  "target_reference": {"query": "", "kind": "quoted_reference|ordinal_reference|ordinal_query_reference|constrained_reference|search_query_reference|", "ordinal": null, "temporal_hint": ""},\n'
        '  "slots": {"to": "", "cc": "", "subject": "", "body": "", "body_instruction": ""}\n'
        "}\n"
        "Rules:\n"
        "- Do not invent facts. Use empty strings when unknown.\n"
        "- If the current message is a follow-up to an earlier email action, merge it with the prior action context.\n"
        "- For update/delete, target_reference.query must identify the target email or draft.\n"
        "- For send, fill slots.to, slots.subject, and slots.body when the user has already provided them explicitly or implicitly in natural language.\n"
        "- You may derive a concise subject from the user's requested message when the topic is clear, but do not invent recipient addresses or substantive facts.\n"
        f"User message: {message}\n"
        f"Deterministic frame: {json.dumps(deterministic_frame.to_dict(), ensure_ascii=False)}"
    )
    if prior_frame is not None:
        prompt += f"\nPrior frame: {json.dumps(prior_frame.to_dict(), ensure_ascii=False)}"
    system = "Return valid JSON only for email intent extraction."
    try:
        raw_result, _used_model = await _call_text_model_candidates(
            prompt,
            model_candidates,
            system=system,
            num_ctx=8192,
            thinking=False,
            per_model_timeout=18.0,
        )
        if _is_llm_error(raw_result):
            return intent
        parsed = _parse_model_json(raw_result, expect="object")
        llm_frame = intent_frame_from_payload(parsed, domain_hint="email")
        llm_intent = email_intent_from_frame(llm_frame) if llm_frame else email_intent_from_llm_payload(parsed)
        merged = merge_email_intents(intent, llm_intent)
        if len(merged.missing_fields) < len(intent.missing_fields) or merged.confidence >= intent.confidence:
            return merged
    except Exception:
        return intent
    return intent
