"""
Calendar workflow helpers — record matching, selection, intent extraction,
provider scope, payloads, and action reports.

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

import json
import re
from datetime import datetime, timedelta

from backend.action_intents import ordinal_to_index
from backend.appointment_intents import (
    AppointmentIntent,
    appointment_intent_from_classifier,
    appointment_intent_from_llm_payload,
    detect_calendar_action,
    merge_appointment_intents,
    parse_appointment_intent,
)
from backend.email_workflow import (
    _email_ordinal_index,
    _normalize_match_text,
    _NUMBER_WORDS,
    _looks_dutch_text,
    _store_session_email_records,
)
from backend.intent_frames import (
    appointment_intent_from_frame,
    appointment_intent_to_frame,
    intent_frame_from_payload,
)
from backend.local_time import localize_calendar_match_datetime
from backend.models import _call_text_model_candidates, _is_llm_error
from backend.parsing import _parse_model_json
from backend.response_composers import (
    compose_direct_results,
    compose_tool_response,
    extract_calendar_lookup_records,
)
from backend.session_context import _session_tool_trace
from backend.task_understanding import _extract_explicit_date_range


# ---------------------------------------------------------------------------
# Calendar constants (defined here, imported back by main.py)
# ---------------------------------------------------------------------------

_CALENDAR_MATCH_STOPWORDS: set[str] = set()          # filled after email_workflow import
_MONTH_NAMES_TO_NUMBER: dict[str, int] = {}
_WEEKDAY_NAMES_TO_INDEX: dict[str, int] = {}


def _init_calendar_constants(
    calendar_stopwords: set[str],
    month_names: dict[str, int],
    weekday_names: dict[str, int],
) -> None:
    """Called once from main.py to inject shared constants."""
    global _CALENDAR_MATCH_STOPWORDS, _MONTH_NAMES_TO_NUMBER, _WEEKDAY_NAMES_TO_INDEX
    _CALENDAR_MATCH_STOPWORDS = calendar_stopwords
    _MONTH_NAMES_TO_NUMBER = month_names
    _WEEKDAY_NAMES_TO_INDEX = weekday_names


# ---------------------------------------------------------------------------
# Record extraction
# ---------------------------------------------------------------------------

def _calendar_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 {"google_read_calendar", "outlook_read_calendar"}:
            continue
        provider = "gmail_only" if tool_name.startswith("google_") else "outlook_only"
        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 []
        if not detail_records and isinstance(tool_details.get("events"), list):
            detail_records = tool_details.get("events") or []
        source_records = detail_records or extract_calendar_lookup_records(str(item.get("tool_result") or ""))
        for record in source_records:
            if not isinstance(record, dict):
                continue
            entry = dict(record)
            entry.setdefault("provider_scope", provider)
            records.append(entry)
    deduped: list[dict] = []
    seen: set[tuple[str, str, str, str]] = set()
    for record in records:
        key = (
            str(record.get("provider_scope") or "").strip().lower(),
            str(record.get("title") or "").strip().lower(),
            str(record.get("start") or "").strip().lower(),
            str(record.get("event_id") or "").strip().lower(),
        )
        if key in seen:
            continue
        seen.add(key)
        deduped.append(record)
    return deduped[:50]


def _store_session_calendar_records(session: dict, trace: list[dict]) -> None:
    records = _calendar_records_from_tool_trace(trace)
    if records:
        session["last_calendar_results"] = records
    else:
        session.pop("last_calendar_results", None)


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


def _store_session_lookup_records_by_domain(session: dict, domain: str, trace: list[dict]) -> None:
    normalized = str(domain or "").strip().lower()
    if normalized == "email":
        _store_session_email_records(session, trace)
    elif normalized == "calendar":
        _store_session_calendar_records(session, trace)


# ---------------------------------------------------------------------------
# Provider scope
# ---------------------------------------------------------------------------

def _detect_calendar_provider_scope(text: str, *, use_outlook: bool, gmail_available: bool) -> str:
    value = str(text or "").lower()
    both_requested = bool(re.search(r"\b(beide|both|allebei|across accounts|all accounts|agenda's|agendas)\b", value))
    has_outlook = bool(re.search(r"\boutlook\b", value))
    has_gmail = bool(re.search(r"\b(gmail|google agenda|google calendar|google)\b", value))
    if both_requested and use_outlook and gmail_available:
        return "cross_provider"
    if has_outlook and not has_gmail:
        return "outlook_only"
    if has_gmail and not has_outlook:
        return "gmail_only"
    if use_outlook and gmail_available:
        return "cross_provider"
    if use_outlook and not gmail_available:
        return "outlook_only"
    if gmail_available and not use_outlook:
        return "gmail_only"
    return "default"


def _calendar_action_type(text: str) -> str:
    return detect_calendar_action(text)


def _calendar_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


# ---------------------------------------------------------------------------
# Intent clarification
# ---------------------------------------------------------------------------

def _calendar_intent_missing_clarification(intent: AppointmentIntent, *, dutch: bool) -> str:
    missing = set(intent.missing_fields)
    if "selection_query" in missing:
        return (
            "Ik mis nog welke afspraak je bedoelt. Noem bijvoorbeeld de titel, datum of het volgnummer."
            if dutch else
            "I still need which event you mean. Please mention the title, date, or list number."
        )
    if "changes" in missing:
        return (
            "Ik mis nog wat er aan de afspraak moet veranderen. Noem bijvoorbeeld een nieuwe tijd, datum, titel of locatie."
            if dutch else
            "I still need what should change in the event. For example: a new time, date, title, or location."
        )
    if "summary" in missing:
        return (
            "Ik mis nog een titel voor de nieuwe afspraak."
            if dutch else
            "I still need a title for the new event."
        )
    if "start" in missing or "end" in missing:
        return (
            "Ik mis nog een datum of tijd voor de afspraak. Noem bijvoorbeeld 'morgen om 09:00' of 'vrijdag van 18:30 tot 21:30'."
            if dutch else
            "I still need a date or time for the event. For example: 'tomorrow at 09:00' or 'Friday from 18:30 to 21:30'."
        )
    return (
        "Ik mis nog details om deze agenda-actie veilig uit te voeren."
        if dutch else
        "I still need a few details to safely carry out this calendar action."
    )


# ---------------------------------------------------------------------------
# Intent extraction
# ---------------------------------------------------------------------------

async def _extract_calendar_intent_with_fallback(
    message: str,
    reference: datetime,
    *,
    model_candidates: list[str],
    classifier_params: dict | None = None,
    classifier_action: str = "",
) -> AppointmentIntent:
    cl_intent: AppointmentIntent | None = None
    if classifier_params and classifier_action:
        cl_intent = appointment_intent_from_classifier(
            classifier_params, classifier_action, message, reference,
        )
        if cl_intent.action and cl_intent.confidence >= 0.7 and not cl_intent.missing_fields:
            return cl_intent

    regex_intent = parse_appointment_intent(message, reference)
    if cl_intent and cl_intent.action:
        intent = merge_appointment_intents(cl_intent, regex_intent)
    else:
        intent = regex_intent

    if not intent.action:
        return intent
    if intent.confidence >= 0.74 and not intent.missing_fields:
        return intent

    deterministic_frame = appointment_intent_to_frame(intent, source_text=message)
    prompt = (
        "Extract a calendar action intent as an IntentFrame JSON object only. Do not add prose.\n"
        "Use this schema exactly:\n"
        "{\n"
        '  "domain": "calendar",\n'
        '  "action": "create|update|delete|",\n'
        '  "target_reference": {"query": "", "kind": "quoted_reference|ordinal_reference|ordinal_query_reference|constrained_reference|search_query_reference|", "ordinal": null, "temporal_hint": ""},\n'
        '  "slots": {"summary": "", "start": "YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS", "end": "YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS", "location": "", "description": "", "attendees": "comma,separated@example.com", "all_day": false}\n'
        "}\n"
        "Rules:\n"
        "- Do not invent facts. Use empty strings when unknown.\n"
        "- For update/delete, target_reference.query must describe the existing event to match.\n"
        "- For create/update, use ISO date/time formats in slots.start and slots.end.\n"
        "- If only a date is clear, use YYYY-MM-DD and slots.all_day=true.\n"
        f"Reference datetime: {reference.strftime('%Y-%m-%dT%H:%M:%S')}\n"
        f"User message: {message}\n"
        f"Deterministic frame: {json.dumps(deterministic_frame.to_dict(), ensure_ascii=False)}"
    )
    system = "Return valid JSON only for calendar 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="calendar")
        llm_intent = appointment_intent_from_frame(llm_frame) if llm_frame else appointment_intent_from_llm_payload(parsed)
        merged = merge_appointment_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


# ---------------------------------------------------------------------------
# Token matching & selection
# ---------------------------------------------------------------------------

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


def _extract_calendar_selection_query(text: str, action: 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()

    if action == "update":
        move_match = re.search(r"\bvan\s+(.+?)\s+naar\s+(.+)$", raw, re.IGNORECASE)
        if move_match:
            return move_match.group(1).strip(" .,;:!?\"'")

    noun_match = re.search(
        r"(?:de|het|mijn|my|these|those)?\s*(?:(?:\d+|een|twee|drie|vier|vijf|one|two|three|four|five)\s+)?(.+?)\s+afspraken?\s+(?:.*?)(?:verwijder\w*|delete|remove|wijzig\w*|update|verplaats\w*|move)",
        raw,
        re.IGNORECASE,
    )
    if noun_match:
        candidate = noun_match.group(1).strip(" .,;:!?\"'")
        candidate = re.sub(r"^(?:kun\s+je|wil\s+je|please|could\s+you|can\s+you)\s+", "", candidate, flags=re.IGNORECASE)
        candidate = re.sub(r"^(?:de|het|mijn|my)\s+", "", candidate, flags=re.IGNORECASE)
        candidate = re.sub(r"^(?:\d+|een|twee|drie|vier|vijf|one|two|three|four|five)\s+", "", candidate, flags=re.IGNORECASE)
        return candidate.strip(" .,;:!?\"'")

    title_match = re.search(r"\b(?:genaamd|called|titel|title)\s+(.+)$", raw, re.IGNORECASE)
    if title_match:
        return title_match.group(1).strip(" .,;:!?\"'")

    tokens = _calendar_match_tokens(raw)
    return " ".join(sorted(tokens))


def _score_calendar_record_match(record: dict, query: str) -> int:
    query_text = str(query or "").strip()
    if not query_text:
        return 0
    haystack = " ".join([
        str(record.get("title") or ""),
        str(record.get("start") or ""),
        str(record.get("end") or ""),
        str(record.get("location") or ""),
    ])
    haystack_norm = _normalize_match_text(haystack)
    title_norm = _normalize_match_text(str(record.get("title") or ""))
    query_norm = _normalize_match_text(query_text)
    score = 0
    if query_norm and title_norm:
        if query_norm in title_norm:
            score += 120 + min(len(query_norm), 40)
        elif title_norm in query_norm and len(title_norm) >= 6:
            score += 90
    if query_norm and haystack_norm and query_norm in haystack_norm:
        score += 60
    overlap = len(_calendar_match_tokens(query_text) & _calendar_match_tokens(haystack))
    if overlap:
        score += overlap * 20
    return score


def _calendar_record_identity(record: dict) -> tuple[str, str, str, str]:
    return (
        _normalize_match_text(str(record.get("title") or "")),
        _calendar_record_datetime_key(record, "start"),
        _calendar_record_datetime_key(record, "end"),
        _normalize_match_text(str(record.get("location") or "")),
    )


def _calendar_record_group_key(record: dict) -> tuple[str, str, str, str]:
    identity = _calendar_record_identity(record)
    if identity != ("", "", "", ""):
        return identity
    return (
        _normalize_match_text(str(record.get("provider_scope") or "")),
        _normalize_match_text(str(record.get("event_id") or "")),
        "",
        "",
    )


def _group_calendar_records(records: list[dict]) -> list[list[dict]]:
    groups: list[list[dict]] = []
    index_by_key: dict[tuple[str, str, str, str], int] = {}
    for record in records:
        key = _calendar_record_group_key(record)
        group_index = index_by_key.get(key)
        if group_index is None:
            index_by_key[key] = len(groups)
            groups.append([record])
            continue
        groups[group_index].append(record)
    return groups


def _calendar_records_represent_single_series(records: list[dict]) -> bool:
    identities = {_calendar_record_identity(record) for record in records if isinstance(record, dict)}
    identities.discard(("", "", "", ""))
    return len(identities) == 1 and bool(records)


def _calendar_record_logical_series_key(record: dict) -> tuple[str, str, str]:
    return (
        _normalize_match_text(str(record.get("title") or "")),
        _calendar_record_datetime_key(record, "start"),
        _calendar_record_datetime_key(record, "end"),
    )


def _calendar_records_represent_single_logical_series(records: list[dict]) -> bool:
    identities = {
        _calendar_record_logical_series_key(record)
        for record in records
        if isinstance(record, dict)
    }
    identities.discard(("", "", ""))
    return len(identities) == 1 and bool(records)


def _calendar_record_matches_temporal_hint(record: dict, temporal_hint: str) -> bool:
    hint = str(temporal_hint or "").strip()
    if not hint:
        return True
    reference = datetime.now()
    target_day = _resolve_calendar_day_phrase(hint, reference)
    if target_day is None:
        normalized_hint = _normalize_match_text(hint)
        start_norm = _normalize_match_text(str(record.get("start") or ""))
        return bool(normalized_hint and normalized_hint in start_norm)
    record_start, _all_day = _calendar_record_datetime(record, "start")
    if record_start is None:
        return False
    return record_start.date() == target_day.date()


def _calendar_relative_day_offset(temporal_hint: str) -> int | None:
    value = str(temporal_hint or "").strip().lower()
    if not value:
        return None
    if re.search(r"\b(vandaag|today)\b", value):
        return 0
    if re.search(r"\b(morgen|tomorrow)\b", value):
        return 1
    if re.search(r"\b(overmorgen)\b", value):
        return 2
    return None


def _select_calendar_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 []

    filtered_records = list(records)
    if reference_temporal_hint:
        temporal_matches = [record for record in filtered_records if _calendar_record_matches_temporal_hint(record, reference_temporal_hint)]
        if temporal_matches:
            filtered_records = temporal_matches
        else:
            relative_offset = _calendar_relative_day_offset(reference_temporal_hint)
            if relative_offset is not None:
                dated_records: list[tuple[dict, datetime]] = []
                for record in filtered_records:
                    record_start, _all_day = _calendar_record_datetime(record, "start")
                    if record_start is not None:
                        dated_records.append((record, record_start))
                if dated_records:
                    base_day = min(record_start.date() for _, record_start in dated_records)
                    target_day = base_day + timedelta(days=relative_offset)
                    fallback_matches = [record for record, record_start in dated_records if record_start.date() == target_day]
                    if fallback_matches:
                        filtered_records = fallback_matches

    scored = [(_score_calendar_record_match(record, query), record) for record in filtered_records]
    scored = [item for item in scored if item[0] > 0]
    scored.sort(key=lambda item: item[0], reverse=True)
    candidates = [record for _, record in scored] if scored else filtered_records
    candidate_groups = _group_calendar_records(candidates)

    ordinal = ordinal_to_index(reference_ordinal, len(candidate_groups))
    if ordinal is None:
        ordinal = _email_ordinal_index(message)
    if ordinal is not None and 0 <= ordinal < len(candidate_groups):
        return list(candidate_groups[ordinal])

    if not scored:
        if requested_count == 1 and _calendar_records_represent_single_logical_series(filtered_records):
            return list(filtered_records)
        if requested_count == 1 and len(candidate_groups) == 1:
            return list(candidate_groups[0])
        if requested_count > 1 and len(candidate_groups) <= requested_count:
            flattened: list[dict] = []
            for group in candidate_groups[:requested_count]:
                flattened.extend(group)
            return flattened
        return []

    if requested_count == 1:
        top_group = candidate_groups[0] if candidate_groups else []
        top_key = _calendar_record_group_key(top_group[0]) if top_group else None
        next_distinct_score = next(
            (
                score for score, record in scored[1:]
                if _calendar_record_group_key(record) != top_key
            ),
            None,
        )
        if len(scored) > 1 and _calendar_records_represent_single_series([record for _, record in scored]):
            return [record for _, record in scored]
        if len(scored) > 1 and _calendar_records_represent_single_logical_series([record for _, record in scored]):
            return [record for _, record in scored]
        if next_distinct_score is not None and next_distinct_score >= scored[0][0] - 8:
            return []
        return list(top_group)
    flattened: list[dict] = []
    for group in candidate_groups[:requested_count]:
        flattened.extend(group)
    return flattened


# ---------------------------------------------------------------------------
# Datetime helpers
# ---------------------------------------------------------------------------

def _parse_rendered_calendar_datetime(value: str) -> tuple[datetime | None, bool]:
    raw = str(value or "").strip()
    if not raw:
        return None, False
    all_day = "hele dag" in raw.lower()
    cleaned = re.sub(r"\s*\(hele dag\)", "", raw, flags=re.IGNORECASE).strip()
    match = re.search(
        r"(?:(?:maandag|dinsdag|woensdag|donderdag|vrijdag|zaterdag|zondag|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+)?(\d{1,2})\s+([a-zA-Z]+)\s+(\d{4})(?:,\s*(\d{1,2}):(\d{2}))?",
        cleaned,
        re.IGNORECASE,
    )
    if not match:
        return None, all_day
    month_name = str(match.group(2) or "").lower()
    month = _MONTH_NAMES_TO_NUMBER.get(month_name)
    if not month:
        return None, all_day
    hour = int(match.group(4) or 0)
    minute = int(match.group(5) or 0)
    return datetime(int(match.group(3)), month, int(match.group(1)), hour, minute), all_day


def _parse_calendar_record_datetime_value(value: str, *, provider_scope: str = "") -> tuple[datetime | None, bool]:
    raw = str(value or "").strip()
    if not raw:
        return None, False

    iso_value = raw.replace("Z", "+00:00")
    if re.fullmatch(r"\d{4}-\d{2}-\d{2}", iso_value):
        try:
            parsed = datetime.strptime(iso_value, "%Y-%m-%d")
            return localize_calendar_match_datetime(parsed, provider_scope=provider_scope), True
        except ValueError:
            pass

    try:
        parsed = datetime.fromisoformat(iso_value)
        all_day = "T" not in iso_value
        return localize_calendar_match_datetime(parsed, provider_scope=provider_scope), all_day
    except ValueError:
        pass

    parsed, all_day = _parse_rendered_calendar_datetime(raw)
    if parsed is None:
        return None, False
    return localize_calendar_match_datetime(parsed, provider_scope=provider_scope), all_day


def _calendar_record_datetime(record: dict, field_name: str) -> tuple[datetime | None, bool]:
    provider_scope = str(record.get("provider_scope") or "")
    iso_value = str(record.get(f"{field_name}_iso") or "")
    parsed, all_day = _parse_calendar_record_datetime_value(iso_value, provider_scope=provider_scope)
    if parsed is not None:
        return parsed, all_day
    return _parse_calendar_record_datetime_value(
        str(record.get(field_name) or ""),
        provider_scope=provider_scope,
    )


def _calendar_record_datetime_key(record: dict, field_name: str) -> str:
    parsed, all_day = _calendar_record_datetime(record, field_name)
    if parsed is None:
        return _normalize_match_text(str(record.get(f"{field_name}_iso") or record.get(field_name) or ""))
    return parsed.strftime("%Y-%m-%d" if all_day else "%Y-%m-%dT%H:%M:%S")


def _resolve_calendar_day_phrase(text: str, reference: datetime) -> datetime | None:
    value = str(text or "").strip().lower()
    if not value:
        return None
    if re.search(r"\b(vandaag|today)\b", value):
        return reference.replace(hour=0, minute=0, second=0, microsecond=0)
    if re.search(r"\b(morgen|tomorrow)\b", value):
        return (reference + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
    if re.search(r"\b(overmorgen)\b", value):
        return (reference + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
    iso_match = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", value)
    if iso_match:
        try:
            return datetime.strptime(iso_match.group(1), "%Y-%m-%d")
        except ValueError:
            return None
    for name, index in _WEEKDAY_NAMES_TO_INDEX.items():
        if re.search(rf"\b{re.escape(name)}\b", value):
            delta = (index - reference.weekday()) % 7
            if delta == 0:
                delta = 7
            return (reference + timedelta(days=delta)).replace(hour=0, minute=0, second=0, microsecond=0)
    return None


def _extract_time_range(text: str) -> tuple[tuple[int, int] | None, tuple[int, int] | None]:
    value = str(text or "")
    range_match = re.search(r"\b(?:van|from)\s+(\d{1,2})[:.](\d{2})\s+(?:tot|to|until|-|–)\s+(\d{1,2})[:.](\d{2})", value, re.IGNORECASE)
    if range_match:
        return (int(range_match.group(1)), int(range_match.group(2))), (int(range_match.group(3)), int(range_match.group(4)))
    single_match = re.search(r"\b(?:om|at)\s+(\d{1,2})[:.](\d{2})", value, re.IGNORECASE)
    if single_match:
        return (int(single_match.group(1)), int(single_match.group(2))), None
    return None, None


# ---------------------------------------------------------------------------
# Payloads
# ---------------------------------------------------------------------------

def _calendar_create_payload(message: str, reference: datetime) -> dict:
    target_day = _resolve_calendar_day_phrase(message, reference)
    start_time, end_time = _extract_time_range(message)
    summary_match = re.search(r"\b(?:genaamd|called|titel|title)\s+(.+)$", str(message or ""), re.IGNORECASE)
    summary = (summary_match.group(1).strip(" .,;:!?\"'") if summary_match else "").strip()
    payload = {
        "summary": summary,
        "start": "",
        "end": "",
        "description": "",
        "location": "",
        "attendees": "",
    }
    if not payload["summary"]:
        payload["summary"] = "Afspraak" if _looks_dutch_text(message) else "Appointment"
    if not target_day:
        return payload
    if start_time:
        start_dt = target_day.replace(hour=start_time[0], minute=start_time[1])
        if end_time:
            end_dt = target_day.replace(hour=end_time[0], minute=end_time[1])
        else:
            end_dt = start_dt + timedelta(hours=1)
        payload["start"] = start_dt.strftime("%Y-%m-%dT%H:%M:%S")
        payload["end"] = end_dt.strftime("%Y-%m-%dT%H:%M:%S")
    else:
        payload["start"] = target_day.strftime("%Y-%m-%d")
        payload["end"] = (target_day + timedelta(days=1)).strftime("%Y-%m-%d")
    return payload


def _calendar_update_directives(message: str, reference: datetime) -> dict:
    raw = str(message or "")
    move_match = re.search(r"\bvan\s+(.+?)\s+naar\s+(.+)$", raw, re.IGNORECASE)
    source_query = _extract_calendar_selection_query(raw, "update")
    target_phrase = ""
    if move_match:
        source_query = move_match.group(1).strip(" .,;:!?\"'") or source_query
        target_phrase = move_match.group(2).strip()
    title_match = re.search(r"\b(?:genaamd|called|titel|title)\s+(.+)$", raw, re.IGNORECASE)
    target_day = _resolve_calendar_day_phrase(target_phrase or raw, reference)
    start_time, end_time = _extract_time_range(target_phrase or raw)
    return {
        "selection_query": source_query,
        "new_summary": (title_match.group(1).strip(" .,;:!?\"'") if title_match else "").strip(),
        "target_day": target_day,
        "start_time": start_time,
        "end_time": end_time,
    }


def _calendar_update_payload_from_record(record: dict, directives: dict) -> dict:
    start_dt, start_all_day = _parse_rendered_calendar_datetime(str(record.get("start") or ""))
    end_dt, end_all_day = _parse_rendered_calendar_datetime(str(record.get("end") or ""))
    payload = {
        "summary": str(directives.get("new_summary") or "").strip(),
        "start": str(directives.get("new_start") or "").strip(),
        "end": str(directives.get("new_end") or "").strip(),
        "description": str(directives.get("new_description") or "").strip(),
        "location": str(directives.get("new_location") or "").strip(),
    }
    if payload["start"]:
        if not payload["end"] and payload["start"]:
            if len(payload["start"]) <= 10:
                try:
                    payload["end"] = (datetime.strptime(payload["start"], "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d")
                except ValueError:
                    payload["end"] = ""
            elif start_dt and end_dt:
                try:
                    parsed_start = datetime.strptime(payload["start"], "%Y-%m-%dT%H:%M:%S")
                    payload["end"] = (parsed_start + (end_dt - start_dt)).strftime("%Y-%m-%dT%H:%M:%S")
                except ValueError:
                    payload["end"] = ""
        return payload
    target_day = directives.get("target_day")
    start_time = directives.get("start_time")
    end_time = directives.get("end_time")
    if isinstance(target_day, datetime) and start_dt:
        new_start = target_day.replace(hour=start_dt.hour, minute=start_dt.minute)
        if start_time:
            new_start = target_day.replace(hour=start_time[0], minute=start_time[1])
        if start_all_day or end_all_day:
            payload["start"] = target_day.strftime("%Y-%m-%d")
            payload["end"] = (target_day + timedelta(days=1)).strftime("%Y-%m-%d")
            return payload
        if end_dt and start_dt:
            duration = end_dt - start_dt
        else:
            duration = timedelta(hours=1)
        if end_time:
            new_end = target_day.replace(hour=end_time[0], minute=end_time[1])
        else:
            new_end = new_start + duration
        payload["start"] = new_start.strftime("%Y-%m-%dT%H:%M:%S")
        payload["end"] = new_end.strftime("%Y-%m-%dT%H:%M:%S")
    return payload


# ---------------------------------------------------------------------------
# Action reports
# ---------------------------------------------------------------------------

def _calendar_action_report(action: str, results: list[tuple[str, str]], *, dutch: bool) -> str:
    if not results:
        return "Ik kon geen agenda-actie uitvoeren." if dutch else "I could not execute any calendar action."
    report_goal = "kun je deze agenda-actie uitvoeren" if dutch else "calendar action"
    if len(results) == 1:
        return compose_direct_results(report_goal, results, results[0][1])
    intro_map = {
        "create": "Ik heb {count} agenda-afspraken aangemaakt." if dutch else "I created {count} calendar events.",
        "update": "Ik heb {count} agenda-afspraken bijgewerkt." if dutch else "I updated {count} calendar events.",
        "delete": "Ik heb {count} agenda-afspraken verwijderd." if dutch else "I deleted {count} calendar events.",
    }
    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)


# ---------------------------------------------------------------------------
# Range guard
# ---------------------------------------------------------------------------

def _apply_calendar_range_guard(direct_calls: list[dict], goal_text: str) -> list[dict]:
    explicit = _extract_explicit_date_range(goal_text)
    if not explicit:
        return list(direct_calls)
    since = str(explicit.get("since") or "").strip()
    until = str(explicit.get("until") or "").strip()
    if not since and not until:
        return list(direct_calls)

    guarded: list[dict] = []
    for call in list(direct_calls or []):
        if not isinstance(call, dict):
            continue
        tool = str(call.get("tool") or "").strip().lower()
        args = dict(call.get("args") or {})
        if tool in {"outlook_read_calendar", "google_read_calendar"}:
            args["days_ahead"] = 1
            if since:
                args["since"] = since
            if until:
                args["until"] = until
        guarded.append({"tool": call.get("tool"), "args": args})
    return guarded
