import re
from datetime import date, datetime, timedelta
from email.utils import parsedate_to_datetime
from pathlib import Path
import json


_BRIEFING_ADAPTIVE_WIDENING_GUIDANCE = (
    "Coverage check and adaptive widening: start compact, validate coverage, "
    "use provider-native search tools where needed, and widen gradually only when evidence stays thin."
)


_MORNING_BRIEF_GOAL_RE = re.compile(r"^co-pilot morning brief for today\.", re.IGNORECASE)
_FOLLOWUP_BRIEF_GOAL_RE = re.compile(r"^co-pilot follow-up brief for today\.", re.IGNORECASE)
_MORNING_BRIEF_REQUEST_RE = re.compile(
    r"\b(please|pls|can you|could you|let me know|confirm|review|reply|response needed|"
    r"kun je|kan je|wil je|graag|bevestig|reageer|antwoord|laat je weten)\b",
    re.IGNORECASE,
)
_MORNING_BRIEF_URGENT_RE = re.compile(
    r"\b(urgent|asap|today|tonight|eod|deadline|important|dringend|spoed|vandaag|vanmiddag|voor \d{1,2}[:.]?\d{0,2})\b",
    re.IGNORECASE,
)
_MORNING_BRIEF_LOW_SIGNAL_RE = re.compile(
    r"\b(newsletter|digest|noreply|no-reply|notification|automated|unsubscribe)\b",
    re.IGNORECASE,
)
_MORNING_BRIEF_PROMO_RE = re.compile(
    r"\b(discount|korting|sale|promo(?:tion(?:al)?)?|aanbieding|offerte aanvragen|webshop)\b",
    re.IGNORECASE,
)
# Terms that are promo-like only when not near business context words.
_MORNING_BRIEF_PROMO_MAYBE_RE = re.compile(
    r"\b(deal|offer|shop)\b", re.IGNORECASE,
)
_MORNING_BRIEF_PROMO_BUSINESS_RE = re.compile(
    r"\b(project|contract|client|klant|case|zaak|dossier|status|meeting|review|proposal|help|assist|team|update)\b",
    re.IGNORECASE,
)
_MORNING_BRIEF_OPERATIONAL_RE = re.compile(
    r"\b(schademelding|declaratie|rekening|factuur|invoice|claim|polis|dossier|zaak|case|project|contract|review request|board review)\b",
    re.IGNORECASE,
)
_MORNING_BRIEF_GENERIC_SENDER_RE = re.compile(
    r"^(info|news|newsletter|hello|marketing|service|contact|mail|email)$",
    re.IGNORECASE,
)
_MORNING_BRIEF_MEETING_RE = re.compile(
    r"\b(review|demo|client|klant|board|interview|presentation|presentatie|kickoff|1:1|one-on-one|steering|sync)\b",
    re.IGNORECASE,
)
_MORNING_BRIEF_SOCIAL_EVENT_RE = re.compile(
    r"\b(verjaardag|birthday|eten|diner|dinner|lunch|borrel|drinks|familie|family|feest)\b",
    re.IGNORECASE,
)
_MORNING_BRIEF_STOPWORDS = {
    "the", "and", "for", "with", "van", "voor", "met", "een", "de", "het", "your", "you", "this",
}
_MORNING_BRIEF_MONTHS = {
    "jan": 1,
    "januari": 1,
    "january": 1,
    "feb": 2,
    "februari": 2,
    "february": 2,
    "mrt": 3,
    "maart": 3,
    "mar": 3,
    "march": 3,
    "apr": 4,
    "april": 4,
    "mei": 5,
    "may": 5,
    "jun": 6,
    "juni": 6,
    "june": 6,
    "jul": 7,
    "juli": 7,
    "july": 7,
    "aug": 8,
    "augustus": 8,
    "august": 8,
    "sep": 9,
    "sept": 9,
    "september": 9,
    "okt": 10,
    "oct": 10,
    "oktober": 10,
    "october": 10,
    "nov": 11,
    "november": 11,
    "dec": 12,
    "december": 12,
}


def is_copilot_morning_brief_goal(goal_text: str) -> bool:
    return bool(_MORNING_BRIEF_GOAL_RE.search(str(goal_text or "").strip()))


def is_copilot_followup_goal(goal_text: str) -> bool:
    return bool(_FOLLOWUP_BRIEF_GOAL_RE.search(str(goal_text or "").strip()))


def _morning_brief_autonomy_level(goal_text: str) -> str:
    text = str(goal_text or "").strip().lower()
    if "only signal what matters" in text or "do not prepare drafts unless explicitly asked" in text:
        return "signal"
    if "prepare draft replies" in text or "prepare draft" in text:
        return "draft"
    return "suggest"


def build_morning_brief_steps(
    *,
    outlook_enabled: bool,
    gmail_enabled: bool,
    outlook_folder_profiles: dict | None = None,
    google_folder_profiles: dict | None = None,
) -> list[dict]:
    email_since_date = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")
    calendar_since_date = datetime.now().strftime("%Y-%m-%d")
    calendar_until_date = (datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")

    email_fetch_parts: list[str] = []
    email_direct_calls: list[tuple[str, dict]] = []
    if outlook_enabled:
        email_fetch_parts.append(
            f"use outlook_get_inbox for Inbox with count=8 and since='{email_since_date}'"
        )
        email_direct_calls.append(("outlook_get_inbox", {"folder": "Inbox", "count": 8, "since": email_since_date}))
    if gmail_enabled:
        email_fetch_parts.append(
            f"use gmail_get_inbox for Inbox with count=8 and since='{email_since_date}'"
        )
        email_direct_calls.append(("gmail_get_inbox", {"folder": "Inbox", "count": 8, "since": email_since_date}))
    if not email_fetch_parts:
        email_fetch_parts.append("use the available email integrations with similarly compact limits")

    # Add extra folder fetches for selected custom folders
    folder_direct_calls: list[tuple[str, dict]] = []
    folder_context_parts: list[str] = []
    if outlook_enabled and outlook_folder_profiles:
        for fpath, desc in list(outlook_folder_profiles.items())[:10]:
            folder_direct_calls.append(("outlook_get_inbox", {
                "folder": fpath, "count": 5, "since": email_since_date,
            }))
            label = desc or fpath.split("/")[-1]
            folder_context_parts.append(f"'{fpath}' ({label})")
    if gmail_enabled and google_folder_profiles:
        for lpath, desc in list(google_folder_profiles.items())[:10]:
            folder_direct_calls.append(("gmail_get_inbox", {
                "folder": lpath, "count": 5, "since": email_since_date,
            }))
            label = desc or lpath.split("/")[-1]
            folder_context_parts.append(f"'{lpath}' ({label})")

    calendar_fetch_parts: list[str] = []
    calendar_direct_calls: list[tuple[str, dict]] = []
    if outlook_enabled:
        calendar_fetch_parts.append(
            f"use outlook_read_calendar with since='{calendar_since_date}' and until='{calendar_until_date}'"
        )
        calendar_direct_calls.append(("outlook_read_calendar", {"since": calendar_since_date, "until": calendar_until_date}))
    if gmail_enabled:
        calendar_fetch_parts.append(
            f"use google_read_calendar with since='{calendar_since_date}' and until='{calendar_until_date}'"
        )
        calendar_direct_calls.append(("google_read_calendar", {"since": calendar_since_date, "until": calendar_until_date}))
    if not calendar_fetch_parts:
        calendar_fetch_parts.append("use the available calendar integrations for today and early tomorrow only")

    steps = [
        {
            "step": (
                "Fetch compact email attention signals for the morning brief; "
                + "; ".join(email_fetch_parts)
                + ". Focus on reply-likely, urgent, or meeting-linked emails. "
                "Use this compact pass as a first signal only, not as final coverage. "
                + _BRIEFING_ADAPTIVE_WIDENING_GUIDANCE
                + " "
                "Do not do large inbox dumps."
            ),
            "needs_search": False,
            "search_hint": "",
            "direct_calls": email_direct_calls,
        },
        {
            "step": (
                "Fetch only today's and the next two days' calendar events that may need preparation or create timing risk; "
                + "; ".join(calendar_fetch_parts)
                + ". Exclude past meetings. Keep the result compact and focused on meetings that could affect today's priorities or the next two days."
            ),
            "needs_search": False,
            "search_hint": "",
            "direct_calls": calendar_direct_calls,
        },
    ]
    if folder_direct_calls:
        steps.append({
            "step": (
                "Fetch recent emails from the user's monitored folders to cross-reference with today's calendar: "
                + "; ".join(folder_context_parts)
                + ". Start with recent messages (last 2 days). "
                "If a folder's topics relate to today's meetings or upcoming deadlines, include relevant signals in the brief. "
                "Skip folders whose topics clearly don't match any calendar event. "
                "If evidence is still thin after this compact pass, run targeted provider-native search tools for those relevant folders "
                "and widen gradually "
                "before concluding."
            ),
            "needs_search": False,
            "search_hint": "",
            "direct_calls": folder_direct_calls,
        })
    return steps


def build_followup_brief_steps(
    *,
    outlook_enabled: bool,
    gmail_enabled: bool,
    outlook_folder_profiles: dict | None = None,
    google_folder_profiles: dict | None = None,
) -> list[dict]:
    email_since_date = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d")
    calendar_since_date = datetime.now().strftime("%Y-%m-%d")
    calendar_until_date = (datetime.now() + timedelta(days=3)).strftime("%Y-%m-%d")

    email_fetch_parts: list[str] = []
    email_direct_calls: list[tuple[str, dict]] = []
    if outlook_enabled:
        email_fetch_parts.append(
            f"use outlook_get_inbox for Inbox with count=12 and since='{email_since_date}'"
        )
        email_direct_calls.append(("outlook_get_inbox", {"folder": "Inbox", "count": 12, "since": email_since_date}))
    if gmail_enabled:
        email_fetch_parts.append(
            f"use gmail_get_inbox for Inbox with count=12 and since='{email_since_date}'"
        )
        email_direct_calls.append(("gmail_get_inbox", {"folder": "Inbox", "count": 12, "since": email_since_date}))
    if not email_fetch_parts:
        email_fetch_parts.append("use the available email integrations with compact limits")

    # Extra folder fetches
    folder_direct_calls: list[tuple[str, dict]] = []
    folder_context_parts: list[str] = []
    if outlook_enabled and outlook_folder_profiles:
        for fpath, desc in list(outlook_folder_profiles.items())[:10]:
            folder_direct_calls.append(("outlook_get_inbox", {
                "folder": fpath, "count": 5, "since": email_since_date,
            }))
            label = desc or fpath.split("/")[-1]
            folder_context_parts.append(f"'{fpath}' ({label})")
    if gmail_enabled and google_folder_profiles:
        for lpath, desc in list(google_folder_profiles.items())[:10]:
            folder_direct_calls.append(("gmail_get_inbox", {
                "folder": lpath, "count": 5, "since": email_since_date,
            }))
            label = desc or lpath.split("/")[-1]
            folder_context_parts.append(f"'{lpath}' ({label})")

    calendar_fetch_parts: list[str] = []
    calendar_direct_calls: list[tuple[str, dict]] = []
    if outlook_enabled:
        calendar_fetch_parts.append(
            f"use outlook_read_calendar with since='{calendar_since_date}' and until='{calendar_until_date}'"
        )
        calendar_direct_calls.append(("outlook_read_calendar", {"since": calendar_since_date, "until": calendar_until_date}))
    if gmail_enabled:
        calendar_fetch_parts.append(
            f"use google_read_calendar with since='{calendar_since_date}' and until='{calendar_until_date}'"
        )
        calendar_direct_calls.append(("google_read_calendar", {"since": calendar_since_date, "until": calendar_until_date}))
    if not calendar_fetch_parts:
        calendar_fetch_parts.append("use the available calendar integrations for today through the next three days")

    steps = [
        {
            "step": (
                "Fetch compact email follow-up signals for the current workstream; "
                + "; ".join(email_fetch_parts)
                + ". For each email, note its Status (Unread/Read/Replied) so that already-replied messages "
                "can be filtered from the action list. "
                "Focus on messages that likely still need a reply, decision, owner action, or attachment review. "
                "Use this compact pass as a first signal only, then validate whether coverage is sufficient. "
                + _BRIEFING_ADAPTIVE_WIDENING_GUIDANCE
            ),
            "needs_search": False,
            "search_hint": "",
            "direct_calls": email_direct_calls,
        },
        {
            "step": (
                "Fetch today's and the next three days' calendar commitments that may still need preparation or create follow-up pressure; "
                + "; ".join(calendar_fetch_parts)
                + ". Exclude past meetings and keep the result focused on items that could still require preparation, decisions, or coordination."
            ),
            "needs_search": False,
            "search_hint": "",
            "direct_calls": calendar_direct_calls,
        },
    ]
    if folder_direct_calls:
        steps.append({
            "step": (
                "Fetch recent emails from monitored folders for follow-up cross-referencing: "
                + "; ".join(folder_context_parts)
                + ". Start with recent messages (last 3 days). "
                "Highlight any open threads or pending items from these folders that relate to upcoming commitments. "
                "If coverage remains weak, widen gradually with provider-native search tools for the relevant folders before finalizing."
            ),
            "needs_search": False,
            "search_hint": "",
            "direct_calls": folder_direct_calls,
        })
    return steps


def build_morning_brief_report_prompt(goal: str, combined_results: str) -> str:
    return (
        f"Morning brief request: {goal}\n\n"
        f"Collected signals:\n{combined_results}\n\n"
        "Write a concise, advice-first morning brief.\n"
        "Start with the 2-3 things that matter most today and explain why they matter.\n"
        "Then give the recommended action order for this morning.\n"
        "Only after that add short supporting bullets for reply-likely emails, meetings needing preparation, and watch items.\n"
        "When email folder data is available, cross-reference folder topics with today's calendar events to give richer, context-aware advice.\n"
        "Avoid exhaustive lists, avoid process narration, and do not repeat every raw mail or calendar item.\n"
        "Write in the same language as the user."
    )


def build_morning_brief_report_system(current_date_line: str) -> str:
    return (
        f"{current_date_line} "
        "You write high-signal executive briefings. Prioritize judgment, sequencing, and recommendations over inventory. "
        "Never narrate process."
    )


def build_followup_brief_report_prompt(goal: str, combined_results: str) -> str:
    return (
        f"Follow-up brief request: {goal}\n\n"
        f"Collected signals:\n{combined_results}\n\n"
        "Write a concise follow-up brief focused on open loops, pending replies, decisions, and meeting preparation.\n"
        "Start with the 2-3 items most likely to stall progress if ignored.\n"
        "Then group the remaining items into: reply-needed, prep-needed, FYI-only (ter info, no action), "
        "and already-replied (already handled — do not include these in action lists).\n"
        "Emails where the user already sent a reply must be listed under 'Already replied' or 'Al beantwoord', "
        "never under action items or open loops.\n"
        "Emails with no clear action request and no urgency should be marked as FYI only.\n"
        "Avoid exhaustive lists, avoid process narration, and do not repeat every raw mail or calendar item.\n"
        "Write in the same language as the user."
    )


def build_followup_brief_report_system(current_date_line: str) -> str:
    return (
        f"{current_date_line} "
        "You write pragmatic work-follow-up briefings. Prioritize unresolved commitments, likely blockers, and concrete next steps over inventory. "
        "Never narrate process."
    )


def _dedupe_preserve_order(items: list[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for item in items:
        normalized = re.sub(r"\s+", " ", str(item or "")).strip()
        if not normalized or normalized in seen:
            continue
        seen.add(normalized)
        out.append(str(item))
    return out


def _stable_variant_index(*values: object, modulo: int) -> int:
    if modulo <= 1:
        return 0
    total = 0
    for value in values:
        if value is None:
            continue
        if isinstance(value, (list, tuple, set)):
            for item in value:
                total += sum(ord(ch) for ch in str(item or ""))
            continue
        total += sum(ord(ch) for ch in str(value or ""))
    return total % modulo


def _pick_variant(options: list[str], *keys: object) -> str:
    if not options:
        return ""
    return options[_stable_variant_index(*keys, modulo=len(options))]


def _looks_dutch(*texts: str) -> bool:
    from backend.response_composers import _looks_dutch as _canonical_looks_dutch
    return _canonical_looks_dutch(*texts)


def _extract_counterparty_label(value: str) -> str:
    raw = str(value or "").strip()
    if not raw:
        return "onbekende afzender"
    angle_match = re.search(r"<([^>]+)>", raw)
    if angle_match:
        name = raw.split("<", 1)[0].strip().strip('"')
        return name or angle_match.group(1).strip()
    return raw


def _extract_email_domain(value: str) -> str:
    raw = str(value or "").strip().lower()
    match = re.search(r"[<\s]?([a-z0-9._%+-]+@([a-z0-9.-]+\.[a-z]{2,}))", raw)
    return str(match.group(2) or "").strip() if match else ""


def _extract_email_local_part(value: str) -> str:
    raw = str(value or "").strip().lower()
    match = re.search(r"([a-z0-9._%+-]+)@[a-z0-9.-]+\.[a-z]{2,}", raw)
    return str(match.group(1) or "").strip() if match else ""


def _brief_tokens(value: str) -> set[str]:
    return {
        token
        for token in re.findall(r"[a-z0-9]{4,}", str(value or "").lower())
        if token not in _MORNING_BRIEF_STOPWORDS
    }


def _parse_morning_brief_event_day(value: str, *, today: date | None = None) -> date | None:
    raw = str(value or "").strip()
    if not raw:
        return None
    today = today or datetime.now().date()
    lowered = raw.lower()
    if "overmorgen" in lowered or "day after tomorrow" in lowered:
        return today + timedelta(days=2)
    if "morgen" in lowered or "tomorrow" in lowered:
        return today + timedelta(days=1)
    if "vandaag" in lowered or "today" in lowered:
        return today

    iso_match = re.search(r"\b(\d{4})-(\d{2})-(\d{2})\b", lowered)
    if iso_match:
        try:
            return date(int(iso_match.group(1)), int(iso_match.group(2)), int(iso_match.group(3)))
        except ValueError:
            return None

    named_month_match = re.search(r"\b(\d{1,2})\s+([a-z]{3,9})\s+(\d{4})\b", lowered)
    if named_month_match:
        month = _MORNING_BRIEF_MONTHS.get(named_month_match.group(2))
        if month:
            try:
                return date(int(named_month_match.group(3)), month, int(named_month_match.group(1)))
            except ValueError:
                return None

    numeric_match = re.search(r"\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b", lowered)
    if numeric_match:
        first = int(numeric_match.group(1))
        second = int(numeric_match.group(2))
        year = int(numeric_match.group(3))
        candidates: list[tuple[int, int]] = []
        if first > 12 and second <= 12:
            candidates.append((second, first))
        elif second > 12 and first <= 12:
            candidates.append((first, second))
        else:
            candidates.append((second, first))
            if first != second:
                candidates.append((first, second))
        for month, day in candidates:
            try:
                return date(year, month, day)
            except ValueError:
                continue
        return None

    day_month_match = re.search(r"\b(\d{1,2})\s+([a-z]{3,9})\b", lowered)
    if day_month_match:
        month = _MORNING_BRIEF_MONTHS.get(day_month_match.group(2))
        if month:
            try:
                inferred = date(today.year, month, int(day_month_match.group(1)))
            except ValueError:
                return None
            if inferred < today - timedelta(days=30):
                try:
                    return date(today.year + 1, month, int(day_month_match.group(1)))
                except ValueError:
                    return None
            return inferred
    return None


def _event_is_within_briefing_window(event: dict, *, today: date | None = None, days_ahead: int = 2) -> bool:
    today = today or datetime.now().date()
    event_day = _parse_morning_brief_event_day(str(event.get("start") or ""), today=today)
    if event_day is None:
        return True
    return today <= event_day <= today + timedelta(days=days_ahead)


def _parse_morning_brief_event_minutes(value: str) -> int | None:
    raw = str(value or "").strip().lower()
    match = re.search(r"\b(\d{1,2})[:.](\d{2})\b", raw)
    if not match:
        return None
    hour = int(match.group(1))
    minute = int(match.group(2))
    if hour > 23 or minute > 59:
        return None
    return hour * 60 + minute


def _parse_briefing_email_datetime(value: str) -> datetime | None:
    raw = str(value or "").strip()
    if not raw or raw == "-":
        return None
    try:
        return datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError:
        pass
    try:
        return parsedate_to_datetime(raw)
    except (TypeError, ValueError, IndexError, OverflowError):
        return None


def _briefing_localize_datetime(value: datetime) -> datetime:
    local_now = datetime.now().astimezone()
    local_tz = local_now.tzinfo
    if value.tzinfo is None:
        return value.replace(tzinfo=local_tz)
    return value.astimezone(local_tz)


def _parse_email_deadline(signal_text: str, *, received_dt: datetime | None = None, now: datetime | None = None) -> datetime | None:
    value = str(signal_text or "").strip()
    if not value:
        return None
    now = now or datetime.now().astimezone()
    reference_dt = received_dt or now
    reference_day = reference_dt.date()
    due_day = _parse_morning_brief_event_day(value, today=reference_day)
    lowered = value.lower()
    if due_day is None and any(token in lowered for token in ("vanmiddag", "this afternoon", "tonight", "eod")):
        due_day = reference_day
    due_minutes = _parse_morning_brief_event_minutes(value)
    if due_day is None and due_minutes is not None:
        due_day = reference_day
    if due_day is None:
        return None
    if due_minutes is None:
        if "vanmiddag" in lowered or "this afternoon" in lowered:
            due_minutes = 17 * 60
        elif "tonight" in lowered:
            due_minutes = 21 * 60
        elif "eod" in lowered:
            due_minutes = 18 * 60
        else:
            due_minutes = 23 * 60 + 59
    hour, minute = divmod(due_minutes, 60)
    return now.replace(year=due_day.year, month=due_day.month, day=due_day.day, hour=hour, minute=minute, second=0, microsecond=0)


def _score_email_time_context(record: dict, *, signal_text: str, linked_to_event: bool, now: datetime | None = None) -> tuple[int, list[str], bool]:
    now = now or datetime.now().astimezone()
    reasons: list[str] = []
    score = 0
    received_raw = str(record.get("date") or record.get("Date") or "").strip()
    received_dt = _parse_briefing_email_datetime(received_raw)
    can_mark_deadline_stale = received_dt is not None
    if received_dt is not None:
        local_received = _briefing_localize_datetime(received_dt)
        age = now - local_received
        age_days = age.total_seconds() / 86400.0
        if age_days <= 0.75:
            score += 2
            reasons.append("is vandaag ontvangen")
        elif age_days <= 2.0:
            score += 1
            reasons.append("is recent ontvangen")
        elif age_days >= 4.0 and not linked_to_event:
            score -= 3
            reasons.append("is al meerdere dagen oud")
        elif age_days >= 2.0 and not linked_to_event:
            score -= 1
            reasons.append("is niet van vandaag")

        deadline_dt = _parse_email_deadline(signal_text, received_dt=local_received, now=now)
    else:
        deadline_dt = _parse_email_deadline(signal_text, now=now)

    stale_deadline = False
    if can_mark_deadline_stale and deadline_dt is not None and deadline_dt < now:
        stale_deadline = True
        if linked_to_event:
            score -= 2
            reasons.append("oude tijdsdruk lijkt verlopen, maar raakt wel een komende afspraak")
        else:
            score -= 6
            reasons.append("tijdsdruk of deadline lijkt al verlopen")

    return score, reasons, stale_deadline


def _collect_morning_brief_inputs(memory: list[dict]) -> tuple[list[dict], list[dict], list[str]]:
    emails: list[dict] = []
    events: list[dict] = []
    sources: list[str] = []
    seen_email_keys: set[str] = set()
    seen_event_keys: set[str] = set()

    for item in memory:
        tool_name = str(item.get("tool_name") or "").strip()
        if not tool_name:
            continue
        tool_result = str(item.get("tool_result") or item.get("result") or "")
        details = dict(item.get("tool_details") or {})
        if not details:
            from backend.response_composers import build_tool_result_details

            details = build_tool_result_details(tool_name, tool_result)
        provenance = str(details.get("provenance_label") or details.get("provider") or tool_name).strip()
        if provenance:
            sources.append(provenance)

        if details.get("kind") == "email_lookup":
            for index, record in enumerate(list(details.get("records") or [])):
                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()
                record_key = str(record.get("message_id") or "").strip() or f"{subject}|{sender}|{index}"
                if record_key in seen_email_keys:
                    continue
                seen_email_keys.add(record_key)
                raw_status = str(record.get("status") or record.get("Status") or "").strip()
                emails.append({
                    **record,
                    "provider": str(details.get("provider") or "").strip(),
                    "folder": str(record.get("folder") or "").strip(),
                    "status": raw_status,
                    "sender_label": _extract_counterparty_label(sender),
                    "domain": _extract_email_domain(sender),
                    "sender_local_part": _extract_email_local_part(sender),
                    "signal_text": " ".join(part for part in [subject, preview, sender] if part).strip(),
                })

        if details.get("kind") == "calendar_lookup":
            for index, event in enumerate(list(details.get("events") or [])):
                title = str(event.get("title") or "").strip()
                start = str(event.get("start") or "").strip()
                event_key = str(event.get("event_id") or "").strip() or f"{title}|{start}|{index}"
                if event_key in seen_event_keys:
                    continue
                if not _event_is_within_briefing_window(event):
                    continue
                seen_event_keys.add(event_key)
                events.append({
                    **event,
                    "provider": str(details.get("provider") or "").strip(),
                })

    return emails, events, _dedupe_preserve_order(sources)


def _score_morning_brief_email(record: dict, events: list[dict], index: int) -> tuple[int, list[str]]:
    score = 0
    reasons: list[str] = []
    folder = str(record.get("folder") or "").strip().lower()
    signal_text = str(record.get("signal_text") or "").strip()
    subject = str(record.get("subject") or "").strip()
    email_status = str(record.get("status") or "").strip().lower()
    is_replied = email_status in {"replied", "beantwoord"}
    is_low_signal = bool(_MORNING_BRIEF_LOW_SIGNAL_RE.search(signal_text) or _MORNING_BRIEF_PROMO_RE.search(signal_text))
    # Ambiguous promo terms ("deal", "offer", "shop") are only promo when no business context
    if not is_low_signal and _MORNING_BRIEF_PROMO_MAYBE_RE.search(signal_text) and not _MORNING_BRIEF_PROMO_BUSINESS_RE.search(signal_text):
        is_low_signal = True
    asks_for_action = bool(_MORNING_BRIEF_REQUEST_RE.search(signal_text))
    has_urgency = bool(_MORNING_BRIEF_URGENT_RE.search(signal_text))
    has_operational_signal = bool(_MORNING_BRIEF_OPERATIONAL_RE.search(signal_text))
    generic_sender = bool(_MORNING_BRIEF_GENERIC_SENDER_RE.search(str(record.get("sender_local_part") or "")))
    linked_to_event = False

    subject_tokens = _brief_tokens(subject)
    if subject_tokens:
        for event in events:
            if subject_tokens & _brief_tokens(str(event.get("title") or "")):
                linked_to_event = True
                score += 4
                reasons.append("lijkt gekoppeld aan een meeting")
                break

    time_score, time_reasons, stale_deadline = _score_email_time_context(
        record,
        signal_text=signal_text,
        linked_to_event=linked_to_event,
    )
    score += time_score
    reasons.extend(time_reasons)

    # Already-replied emails do not need follow-up action; penalize heavily
    if is_replied:
        score -= 8
        reasons.append("al beantwoord — geen actie nodig")

    if folder == "drafts":
        score += 4
        reasons.append("draft wacht op afronding")
    if asks_for_action and not stale_deadline:
        score += 5
        reasons.append("lijkt om een reactie of beslissing te vragen")
    elif asks_for_action and stale_deadline and not linked_to_event:
        score -= 2
        reasons.append("actieverzoek lijkt aan oude tijdsdruk gekoppeld")
    if has_urgency and not stale_deadline:
        score += 3
        reasons.append("heeft urgentie of tijdsdruk")
    if has_operational_signal:
        score += 3
        reasons.append("lijkt operationeel of zakelijk relevant")
    if record.get("domain") and record.get("domain") not in {"gmail.com", "outlook.com", "hotmail.com"} and not is_low_signal:
        score += 1
        reasons.append("komt van een externe partij")
    if generic_sender and not asks_for_action and not has_urgency and not has_operational_signal:
        score -= 3
        reasons.append("komt van een generieke afzender zonder duidelijk actieverzoek")
    if is_low_signal:
        score -= 6
        reasons.append("lijkt laag-signaal of geautomatiseerd")
    if index < 2 and not is_low_signal and not is_replied:
        score += 1
        reasons.append("is recent in de lijst")

    if (is_low_signal or generic_sender) and not asks_for_action and not has_operational_signal and folder != "drafts":
        score = min(score, 0)

    # Apply feedback-learned adjustments
    feedback_adj = _get_feedback_adjustments()
    if feedback_adj:
        record_tokens = _brief_tokens(f"{subject} {signal_text}")
        adj = sum(feedback_adj.get(tok, 0) for tok in record_tokens)
        if adj:
            clamped = max(-3, min(3, adj))
            score += clamped
            if clamped > 0:
                reasons.append("eerder als nuttig gemarkeerd")
            elif clamped < 0:
                reasons.append("eerder als niet nuttig gemarkeerd")

    return score, reasons


def _score_morning_brief_event(event: dict, index: int) -> tuple[int, list[str]]:
    score = 1
    reasons: list[str] = []
    title = str(event.get("title") or "").strip()
    start_text = str(event.get("start") or "").strip()
    location = str(event.get("location") or "").strip()
    lowered_title = title.lower()
    today = datetime.now().date()
    event_day = _parse_morning_brief_event_day(start_text, today=today)
    event_minutes = _parse_morning_brief_event_minutes(start_text)
    if _MORNING_BRIEF_MEETING_RE.search(title):
        score += 4
        reasons.append("lijkt voorbereiding of afstemming te vragen")
    if _MORNING_BRIEF_SOCIAL_EVENT_RE.search(title):
        score -= 4
        reasons.append("lijkt eerder sociaal of prive dan werkgerelateerd")
    if lowered_title in {"afspraak", "appointment", "busy", "bezet", "meeting"}:
        score -= 3
        reasons.append("titel is te generiek voor sterk advies")
    if "hele dag" in start_text.lower() or "all day" in start_text.lower():
        score -= 2
        reasons.append("is een hele-dag item zonder specifieke timing")
    if location and location != "-":
        score += 1
        reasons.append("heeft locatie of logistiek")
    if event_day == today:
        if event_minutes is not None and event_minutes < 11 * 60:
            score += 4
            reasons.append("vindt vanochtend plaats")
        elif event_minutes is not None and event_minutes < 14 * 60:
            score += 2
            reasons.append("staat vandaag rond het middaguur gepland")
        elif event_minutes is not None and event_minutes >= 17 * 60:
            score -= 1
            reasons.append("vindt pas later vandaag plaats")
        elif event_minutes is None and "hele dag" not in start_text.lower():
            score += 1
            reasons.append("is vandaag relevant")
    elif event_day == today + timedelta(days=1):
        if event_minutes is not None and event_minutes < 12 * 60:
            score += 2
            reasons.append("staat morgenochtend gepland")
        else:
            score += 1
            reasons.append("staat morgen gepland")
    elif event_day == today + timedelta(days=2) and event_minutes is not None and event_minutes < 12 * 60:
        score += 1
        reasons.append("staat over twee dagen vroeg gepland")
    elif index < 1 and event_day is None:
        score += 1
        reasons.append("staat bovenaan de agendaweergave")
    return score, reasons


def _hide_low_signal_briefing_email(record: dict, reasons: list[str] | None = None) -> bool:
    signal_text = str(record.get("signal_text") or "").strip()
    folder = str(record.get("folder") or "").strip().lower()
    if folder == "drafts":
        return False

    is_low_signal = bool(_MORNING_BRIEF_LOW_SIGNAL_RE.search(signal_text) or _MORNING_BRIEF_PROMO_RE.search(signal_text))
    if not is_low_signal and _MORNING_BRIEF_PROMO_MAYBE_RE.search(signal_text) and not _MORNING_BRIEF_PROMO_BUSINESS_RE.search(signal_text):
        is_low_signal = True
    if reasons:
        reason_text = " ".join(str(reason or "") for reason in reasons).lower()
        if "laag-signaal" in reason_text or "geautomatiseerd" in reason_text:
            is_low_signal = True
        if (
            ("deadline lijkt al verlopen" in reason_text or "oude tijdsdruk" in reason_text or "oude tijdsdruk gekoppeld" in reason_text)
            and "komende afspraak" not in reason_text
        ):
            return True

    asks_for_action = bool(_MORNING_BRIEF_REQUEST_RE.search(signal_text))
    has_urgency = bool(_MORNING_BRIEF_URGENT_RE.search(signal_text))
    has_operational_signal = bool(_MORNING_BRIEF_OPERATIONAL_RE.search(signal_text))
    generic_sender = bool(_MORNING_BRIEF_GENERIC_SENDER_RE.search(str(record.get("sender_local_part") or "")))

    return (is_low_signal or generic_sender) and not asks_for_action and not has_urgency and not has_operational_signal


def compose_morning_brief_report(goal: str, memory: list[dict]) -> str:
    emails, events, sources = _collect_morning_brief_inputs(memory)
    if not emails and not events:
        return ""

    dutch = _looks_dutch(goal)
    autonomy_level = _morning_brief_autonomy_level(goal)
    scored_emails = []
    for index, record in enumerate(emails):
        score, reasons = _score_morning_brief_email(record, events, index)
        scored_emails.append((score, reasons, record))
    scored_events = []
    for index, event in enumerate(events):
        score, reasons = _score_morning_brief_event(event, index)
        scored_events.append((score, reasons, event))

    scored_emails.sort(key=lambda item: item[0], reverse=True)
    scored_events.sort(key=lambda item: item[0], reverse=True)

    visible_scored_emails = [
        item for item in scored_emails
        if not _hide_low_signal_briefing_email(item[2], item[1])
    ]

    top_email_items = [item for item in visible_scored_emails if item[0] >= 2][:3]
    top_event_items = [item for item in scored_events if item[0] >= 2][:3]
    fallback_email_items = [item for item in visible_scored_emails if item[0] >= 0][:3]
    all_upcoming_event_items = scored_events[:3]
    fallback_event_items = [
        item for item in scored_events
        if item[0] >= 0 and "lijkt eerder sociaal of prive dan werkgerelateerd" not in item[1]
    ][:3]
    if not fallback_event_items:
        fallback_event_items = all_upcoming_event_items[:2]
    today = datetime.now().date()

    def _event_timing_advice(record: dict) -> str:
        title = str(record.get("title") or "de afspraak").strip()
        start_text = str(record.get("start") or "").strip()
        event_day = _parse_morning_brief_event_day(start_text, today=today)
        event_minutes = _parse_morning_brief_event_minutes(start_text)
        if event_day == today and event_minutes is not None and event_minutes < 13 * 60:
            return f"Bereid {title} eerst voor en check of er nog context of stukken nodig zijn."
        if event_day == today:
            return f"Plan later vandaag kort voorbereidingstijd voor {title}, zodat je vlak voor de afspraak niets hoeft uit te zoeken."
        if event_day == today + timedelta(days=1):
            return f"Reserveer vandaag kort tijd om {title} voor morgen voor te bereiden."
        return f"Houd {title} in beeld en blok op tijd een kort voorbereidingsmoment in."

    def _append_section(lines: list[str], title: str, blocks: list[list[str] | str]) -> None:
        if not blocks:
            return
        if lines:
            lines.append("")
        lines.append(f"**{title}**")
        for index, block in enumerate(blocks):
            if isinstance(block, str):
                lines.append(block)
            else:
                lines.extend(block)
            if index != len(blocks) - 1:
                lines.append("")

    def _event_priority_label(record: dict, reasons: list[str], score: int) -> str:
        title = str(record.get("title") or "").strip()
        if any("sociaal" in reason.lower() or "prive" in reason.lower() for reason in reasons):
            return _pick_variant(["Afspraak", "Agenda-item"], title, score)
        return _pick_variant(["Meeting", "Afspraak", "Agenda-item"], title, score, reasons[:2])

    def _email_priority_label(record: dict, score: int) -> str:
        subject = str(record.get("subject") or "").strip()
        sender = str(record.get("sender_label") or "").strip()
        return _pick_variant(["Mail", "Inboxsignaal", "Bericht"], subject, sender, score)

    priority_blocks: list[list[str]] = []
    for score, reasons, record in top_event_items[:2]:
        title = str(record.get("title") or "(zonder titel)").strip()
        start = str(record.get("start") or "-").strip()
        reason_text = ", ".join(_dedupe_preserve_order(reasons[:2]))
        label = _event_priority_label(record, reasons, score)
        priority_blocks.append([
            f"- {label}: {title}",
            f"  Wanneer: {start}",
            f"  Waarom: {reason_text}.",
        ])
    for score, reasons, record in top_email_items[:2]:
        subject = str(record.get("subject") or "(zonder onderwerp)").strip()
        sender = str(record.get("sender_label") or "onbekende afzender").strip()
        reason_text = ", ".join(_dedupe_preserve_order(reasons[:2]))
        label = _email_priority_label(record, score)
        priority_blocks.append([
            f"- {label}: {subject}",
            f"  Afzender: {sender}",
            f"  Waarom: {reason_text}.",
        ])
    priority_blocks = priority_blocks[:3]

    if not priority_blocks:
        if fallback_event_items:
            _, reasons, record = fallback_event_items[0]
            title = str(record.get("title") or "(zonder titel)").strip()
            start = str(record.get("start") or "-").strip()
            if "lijkt eerder sociaal of prive dan werkgerelateerd" in reasons:
                priority_blocks.append([
                    f"- Vandaag oogt rustig; er is geen zakelijke afspraak die directe voorbereiding vraagt.",
                    f"  Wel op je agenda: {title} ({start}).",
                ])
            else:
                reason_text = ", ".join(_dedupe_preserve_order(reasons[:2])) or "staat binnenkort op je agenda"
                priority_blocks.append([
                    f"- Vandaag oogt rustig; de belangrijkste vooruitblik is {title} ({start}).",
                    f"  Waarom: {reason_text}.",
                ])
        if fallback_email_items:
            _, reasons, record = fallback_email_items[0]
            subject = str(record.get("subject") or "(zonder onderwerp)").strip()
            sender = str(record.get("sender_label") or "onbekende afzender").strip()
            reason_text = ", ".join(_dedupe_preserve_order(reasons[:2])) or "kan later vandaag nog aandacht vragen"
            priority_blocks.append([
                f"- Er is geen directe brandhaard in mail; houd wel '{subject}' in beeld.",
                f"  Afzender: {sender}",
                f"  Waarom: {reason_text}.",
            ])
        priority_blocks = priority_blocks[:3]

    action_lines: list[str] = []
    if top_event_items:
        top_event = top_event_items[0][2]
        if autonomy_level == "signal":
            title = str(top_event.get("title") or "de afspraak").strip()
            action_lines.append(f"1. Signaal: {title} vraagt waarschijnlijk voorbereiding of extra contextcheck.")
        elif autonomy_level == "draft":
            action_lines.append(f"1. {_event_timing_advice(top_event)} Zet desnoods alvast een korte prepnotitie of conceptagenda klaar.")
        else:
            action_lines.append(f"1. {_event_timing_advice(top_event)}")
    if top_email_items:
        top_email = top_email_items[0][2]
        subject = str(top_email.get('subject') or '(zonder onderwerp)').strip()
        sender = str(top_email.get('sender_label') or 'de afzender').strip()
        if autonomy_level == "signal":
            action_lines.append(
                f"{len(action_lines) + 1}. Signaal: '{subject}' van {sender} lijkt een reactie of besluit te vragen."
            )
        elif autonomy_level == "draft":
            action_lines.append(
                f"{len(action_lines) + 1}. Bereid zo nodig alvast een conceptreactie voor op '{subject}' van {sender}, of bepaal eerst de beslisrichting."
            )
        else:
            action_lines.append(
                f"{len(action_lines) + 1}. Reageer of beslis over '{subject}' van {sender}."
            )
    if autonomy_level == "draft" and len(top_email_items) > 1 and str(top_email_items[1][2].get("folder") or "").strip().lower() == "drafts":
        draft = top_email_items[1][2]
        action_lines.append(
            f"{len(action_lines) + 1}. Rond de conceptmail '{str(draft.get('subject') or '(zonder onderwerp)').strip()}' af als die vandaag nog weg moet."
        )
    if not action_lines:
        if fallback_event_items:
            fallback_event = fallback_event_items[0][2]
            if autonomy_level == "signal":
                action_lines.append(
                    f"1. Signaal: {str(fallback_event.get('title') or 'de afspraak').strip()} verdient later vandaag een korte contextcheck."
                )
            elif autonomy_level == "draft":
                action_lines.append(f"1. {_event_timing_advice(fallback_event)} Zet indien nuttig alvast een korte voorbereiding klaar.")
            else:
                action_lines.append(f"1. {_event_timing_advice(fallback_event)}")
        if fallback_email_items:
            fallback_email = fallback_email_items[0][2]
            subject = str(fallback_email.get('subject') or '(zonder onderwerp)').strip()
            sender = str(fallback_email.get('sender_label') or 'de afzender').strip()
            if autonomy_level == "signal":
                action_lines.append(
                    f"{len(action_lines) + 1}. Signaal: houd '{subject}' van {sender} later vandaag in beeld voor een mogelijke reactie of beslissing."
                )
            elif autonomy_level == "draft":
                action_lines.append(
                    f"{len(action_lines) + 1}. Check later vandaag of '{subject}' van {sender} een conceptreactie of besluitvoorstel vraagt."
                )
            else:
                action_lines.append(
                    f"{len(action_lines) + 1}. Check later vandaag of '{subject}' van {sender} nog om een reactie of beslissing vraagt."
                )
        if not action_lines:
            action_lines.append("1. Er zijn geen sterke urgente signalen; houd alleen je eerstvolgende afspraak en inbox in de gaten.")

    action_section_title_nl = "Belangrijkste signalen" if autonomy_level == "signal" else "Aanbevolen volgorde"
    action_section_title_en = "Key signals" if autonomy_level == "signal" else "Recommended action order"

    def _join_titles(values: list[str]) -> str:
        cleaned = [value.strip() for value in values if value and value.strip()]
        if not cleaned:
            return ""
        if len(cleaned) == 1:
            return cleaned[0]
        if len(cleaned) == 2:
            return f"{cleaned[0]} en {cleaned[1]}"
        return f"{', '.join(cleaned[:-1])} en {cleaned[-1]}"

    agenda_lines: list[str] = []
    agenda_source = top_event_items[:2] or fallback_event_items[:2]
    for score, reasons, record in agenda_source:
        agenda_lines.append(
            f"- **{str(record.get('title') or '(zonder titel)').strip()}** | {str(record.get('start') or '-').strip()}"
        )

    opening_lines: list[str] = []
    if dutch:
        top_email_subjects = [str(item[2].get("subject") or "").strip() for item in top_email_items[:2]]
        top_event_titles = [str(item[2].get("title") or "").strip() for item in top_event_items[:2]]
        agenda_titles = [str(item[2].get("title") or "").strip() for item in agenda_source[:2]]
        email_focus = _join_titles(top_email_subjects)
        event_focus = _join_titles(top_event_titles)
        agenda_focus = _join_titles(agenda_titles)
        intro_parts: list[str] = []
        if email_focus and event_focus:
            intro_parts.append(_pick_variant([
                f"Je ochtend heeft twee duidelijke ankers: begin bij {email_focus} en houd {event_focus} daarnaast scherp in beeld.",
                f"De slimste start lijkt me om eerst {email_focus} af te vangen en {event_focus} alvast op je radar te zetten.",
                f"Als ik je ochtend logisch indeel, zou ik {email_focus} eerst trekken en {event_focus} vervolgens niet uit het oog verliezen.",
            ], email_focus, event_focus, agenda_focus, len(top_email_items), len(top_event_items)))
        elif email_focus:
            intro_parts.append(_pick_variant([
                f"Als ik meteen op de kern ga zitten, zou ik vanochtend starten met {email_focus}.",
                f"Je meest logische eerste zet lijkt me om {email_focus} als eerste af te vangen.",
                f"De meeste winst pak je vanochtend waarschijnlijk door eerst {email_focus} op te pakken.",
            ], email_focus, agenda_focus, len(top_email_items)))
        elif event_focus:
            intro_parts.append(_pick_variant([
                f"De agenda zet vandaag de toon, dus ik zou je aandacht vooral bij {event_focus} leggen.",
                f"Als je ochtend ergens om draait, dan is het vooral {event_focus} goed voorbereiden of in beeld houden.",
                f"Het zwaartepunt van vandaag lijkt in je agenda te zitten, met {event_focus} als belangrijkste focus.",
            ], event_focus, agenda_focus, len(top_event_items)))
        else:
            intro_parts.append(_pick_variant([
                "Vandaag oogt beheersbaar; er springt niets uit waar je direct achteraan hoeft.",
                "Je ochtend lijkt relatief rustig, zonder duidelijke brandhaard in mail of agenda.",
                "Op het eerste gezicht is dit vooral een dag om rustig regie te houden, niet om te blussen.",
            ], len(fallback_email_items), len(agenda_source), sources))
        if agenda_focus:
            intro_parts.append(_pick_variant([
                f"Verder staan {agenda_focus} op je agenda, dus het helpt om daar alvast wat mentale ruimte voor vrij te houden.",
                f"Daarnaast staat {agenda_focus} op je radar; even vooruitdenken voorkomt later op de dag schakelpijn.",
                f"Hou ook {agenda_focus} in je achterhoofd, zodat je daar straks niet koud in hoeft.",
            ], agenda_focus, event_focus, len(agenda_source)))
        if top_email_items:
            intro_parts.append(_pick_variant([
                "Als je die eerste punten vroeg afvangt, voelt de rest van de ochtend waarschijnlijk een stuk rustiger.",
                "Pak je die eerste signalen vroeg, dan creëer je meteen lucht voor de rest van de ochtend.",
                "Als je dit vroeg rechtzet, hou je later op de ochtend meer ruimte over voor normaal werk.",
            ], top_email_subjects, top_event_titles))
        elif fallback_email_items or agenda_source:
            intro_parts.append(_pick_variant([
                "Met een korte check-in later vanochtend houd je de regie zonder nu alles open te trekken.",
                "Een korte check later op de ochtend is waarschijnlijk genoeg om voor te blijven zonder nu al te verdiepen.",
                "Je hoeft nu nog niet alles uit te pluizen; een gerichte check later vanochtend is waarschijnlijk voldoende.",
            ], fallback_email_items[:1], agenda_source[:1], sources))
        opening_lines = [" ".join(intro_parts)]
    else:
        top_email_subjects = [str(item[2].get("subject") or "").strip() for item in top_email_items[:2]]
        top_event_titles = [str(item[2].get("title") or "").strip() for item in top_event_items[:2]]
        agenda_titles = [str(item[2].get("title") or "").strip() for item in agenda_source[:2]]
        email_focus = _join_titles(top_email_subjects)
        event_focus = _join_titles(top_event_titles)
        agenda_focus = _join_titles(agenda_titles)
        intro_parts = []
        if email_focus and event_focus:
            intro_parts.append(_pick_variant([
                f"Your morning has two clear anchors: start with {email_focus} and keep {event_focus} firmly in view.",
                f"The cleanest start is to deal with {email_focus} first and keep {event_focus} on your radar.",
                f"If I sequence the morning pragmatically, I would tackle {email_focus} first and not lose sight of {event_focus}.",
            ], email_focus, event_focus, agenda_focus, len(top_email_items), len(top_event_items)))
        elif email_focus:
            intro_parts.append(_pick_variant([
                f"The most useful first move this morning is probably to handle {email_focus}.",
                f"If you start anywhere, start with {email_focus}.",
                f"The quickest way to create space this morning is to deal with {email_focus} first.",
            ], email_focus, agenda_focus, len(top_email_items)))
        elif event_focus:
            intro_parts.append(_pick_variant([
                f"The agenda sets the tone today, so I would keep your focus on {event_focus}.",
                f"The main center of gravity today looks like {event_focus}.",
                f"If one thing deserves attention from the agenda, it is {event_focus}.",
            ], event_focus, agenda_focus, len(top_event_items)))
        else:
            intro_parts.append(_pick_variant([
                "Today looks manageable at first glance, with nothing that appears to demand an immediate jump.",
                "At first pass, this looks more like a steady day than a reactive one.",
                "There is no obvious fire to put out right now; this looks like a day to manage with calm control.",
            ], len(fallback_email_items), len(agenda_source), sources))
        if agenda_focus:
            intro_parts.append(_pick_variant([
                f"You also have {agenda_focus} on the agenda, so it is worth holding a bit of mental space for that.",
                f"{agenda_focus} is also coming up, so a little advance awareness will help.",
                f"Keep {agenda_focus} in view as well, so you do not have to switch cold later on.",
            ], agenda_focus, event_focus, len(agenda_source)))
        if top_email_items:
            intro_parts.append(_pick_variant([
                "If you clear those first items early, the rest of the morning will likely feel lighter.",
                "Catching those first signals early should make the rest of the morning feel noticeably easier.",
                "If you deal with those items early, you will probably buy yourself more room for the rest of the morning.",
            ], top_email_subjects, top_event_titles))
        elif fallback_email_items or agenda_source:
            intro_parts.append(_pick_variant([
                "A short check-in later this morning should be enough to stay ahead of it.",
                "You probably do not need a deep dive now; a short check later this morning should be enough.",
                "A targeted check later in the morning should be sufficient to stay in control.",
            ], fallback_email_items[:1], agenda_source[:1], sources))
        opening_lines = [" ".join(intro_parts)]

    support_sections: list[tuple[str, list[str]]] = []
    if top_email_items:
        email_lines: list[str] = []
        for score, reasons, record in top_email_items:
            email_lines.append(
                f"- {str(record.get('subject') or '(zonder onderwerp)').strip()} | {str(record.get('sender_label') or 'onbekende afzender').strip()}"
            )
        support_sections.append(("Reply-likely mails", email_lines))

    watch_source = [item for item in visible_scored_emails if item[0] == 1][:2] or fallback_email_items[:2]
    watch_lines = [
        f"- {str(item[2].get('subject') or '(zonder onderwerp)').strip()} | {str(item[2].get('sender_label') or 'onbekende afzender').strip()}"
        for item in watch_source
    ]

    if dutch:
        lines = ["**Ochtendbrief**"]
        lines.extend(opening_lines)
        _append_section(lines, "Belangrijkste aandacht", priority_blocks or [["- Geen duidelijke acute prioriteiten uit mail of agenda."]])
        _append_section(lines, action_section_title_nl, action_lines)
        _append_section(lines, "Agenda in beeld", agenda_lines)
        if support_sections:
            support_blocks: list[list[str]] = []
            for title, entries in support_sections:
                support_blocks.append([f"**{title}:**"])
                support_blocks[-1].extend(entries)
            _append_section(lines, "Ondersteunende signalen", support_blocks)
        _append_section(lines, "Watch items", watch_lines)
        if sources:
            _append_section(lines, "Bronnen", [f"- {', '.join(sources)}."])
        return "\n".join(lines).strip()

    lines = ["**Morning Brief**"]
    lines.extend(opening_lines)
    _append_section(lines, "Top priorities", priority_blocks or [["- No strong urgent signals from mail or calendar."]])
    _append_section(lines, action_section_title_en, action_lines)
    _append_section(lines, "Agenda in view", agenda_lines)
    if support_sections:
        support_blocks = []
        for title, entries in support_sections:
            support_blocks.append([f"**{title}:**"])
            support_blocks[-1].extend(entries)
        _append_section(lines, "Supporting signals", support_blocks)
    _append_section(lines, "Watch items", watch_lines)
    if sources:
        _append_section(lines, "Sources", [f"- {', '.join(sources)}."])
    return "\n".join(lines).strip()


# ── Briefing feedback storage ─────────────────────────────────────────────────

_FEEDBACK_FILE = Path("memory") / "briefing_feedback.json"
_MAX_FEEDBACK = 100
_FEEDBACK_ADJUSTMENT_CACHE: dict | None = None
_FEEDBACK_ADJUSTMENT_TS: float = 0.0
_FEEDBACK_ADJUSTMENT_TTL: float = 300.0  # 5 min cache


def _load_briefing_feedback() -> list[dict]:
    try:
        if _FEEDBACK_FILE.exists():
            return json.loads(_FEEDBACK_FILE.read_text(encoding="utf-8"))
    except Exception:
        pass
    return []


def _save_briefing_feedback(entries: list[dict]) -> None:
    _FEEDBACK_FILE.parent.mkdir(parents=True, exist_ok=True)
    _FEEDBACK_FILE.write_text(
        json.dumps(entries[-_MAX_FEEDBACK:], ensure_ascii=False, indent=1),
        encoding="utf-8",
    )


def _get_feedback_adjustments() -> dict[str, int]:
    """Build a {lowercased_token: score_delta} map from historical briefing feedback.

    Tokens that appear repeatedly in helpful_items get a positive boost;
    tokens in unhelpful_items get a penalty.  Cached for 5 minutes.
    """
    global _FEEDBACK_ADJUSTMENT_CACHE, _FEEDBACK_ADJUSTMENT_TS
    import time as _t
    now = _t.time()
    if _FEEDBACK_ADJUSTMENT_CACHE is not None and (now - _FEEDBACK_ADJUSTMENT_TS) < _FEEDBACK_ADJUSTMENT_TTL:
        return _FEEDBACK_ADJUSTMENT_CACHE

    entries = _load_briefing_feedback()
    token_scores: dict[str, int] = {}
    for entry in entries:
        for item in (entry.get("helpful_items") or []):
            for tok in _brief_tokens(str(item)):
                token_scores[tok] = token_scores.get(tok, 0) + 1
        for item in (entry.get("unhelpful_items") or []):
            for tok in _brief_tokens(str(item)):
                token_scores[tok] = token_scores.get(tok, 0) - 1
    _FEEDBACK_ADJUSTMENT_CACHE = token_scores
    _FEEDBACK_ADJUSTMENT_TS = now
    return token_scores


def record_briefing_feedback(
    briefing_type: str = "morning",
    rating: str = "thumbs_up",
    helpful_items: list | None = None,
    unhelpful_items: list | None = None,
    comment: str = "",
) -> dict:
    """Record user feedback on a copilot briefing. Returns the stored entry."""
    entry = {
        "timestamp": datetime.now().isoformat(),
        "briefing_type": briefing_type,
        "rating": rating,
        "helpful_items": helpful_items or [],
        "unhelpful_items": unhelpful_items or [],
        "comment": comment[:500],
    }
    entries = _load_briefing_feedback()
    entries.append(entry)
    _save_briefing_feedback(entries)
    try:
        from backend.memory import log_system_event
        log_system_event("briefing_feedback", f"{briefing_type} briefing: {rating}", {"comment": comment[:200]})
    except Exception:
        pass
    return entry

def compose_followup_brief_report(goal: str, memory: list[dict]) -> str:
    emails, events, sources = _collect_morning_brief_inputs(memory)
    if not emails and not events:
        return ""

    dutch = _looks_dutch(goal)
    autonomy_level = _morning_brief_autonomy_level(goal)
    scored_emails = []
    for index, record in enumerate(emails):
        score, reasons = _score_morning_brief_email(record, events, index)
        scored_emails.append((score, reasons, record))
    scored_events = []
    for index, event in enumerate(events):
        score, reasons = _score_morning_brief_event(event, index)
        scored_events.append((score, reasons, event))

    scored_emails.sort(key=lambda item: item[0], reverse=True)
    scored_events.sort(key=lambda item: item[0], reverse=True)

    visible_scored_emails = [
        item for item in scored_emails
        if not _hide_low_signal_briefing_email(item[2], item[1])
    ]

    def _is_replied(record: dict) -> bool:
        return str(record.get("status") or "").strip().lower() in {"replied", "beantwoord"}

    def _is_fyi_only(score: int, reasons: list[str], record: dict) -> bool:
        """FYI: not replied, but no strong action cue and score below action threshold."""
        if _is_replied(record):
            return False
        return score < 2 and "lijkt om een reactie of beslissing te vragen" not in reasons and \
               str(record.get("folder") or "").strip().lower() != "drafts"

    # Separate buckets: replied, needs-action, fyi/watch
    replied_items = [(s, r, rec) for s, r, rec in visible_scored_emails if _is_replied(rec)]
    open_email_items = [item for item in visible_scored_emails if item[0] >= 2 and not _is_replied(item[2])][:4]
    fyi_email_items = [item for item in visible_scored_emails if _is_fyi_only(item[0], item[1], item[2])][:3]
    watch_email_items = [item for item in visible_scored_emails if 0 <= item[0] < 2 and not _is_replied(item[2])][:3]
    prep_event_items = [
        item for item in scored_events
        if item[0] >= 1 and "lijkt eerder sociaal of prive dan werkgerelateerd" not in item[1]
    ][:3]

    def _append_section(lines: list[str], title: str, blocks: list[list[str] | str]) -> None:
        if not blocks:
            return
        if lines:
            lines.append("")
        lines.append(f"**{title}**")
        for index, block in enumerate(blocks):
            if isinstance(block, str):
                lines.append(block)
            else:
                lines.extend(block)
            if index != len(blocks) - 1:
                lines.append("")

    def _followup_email_action(subject: str, sender: str) -> str:
        if autonomy_level == "signal":
            return f"Signaal: '{subject}' van {sender} lijkt nog open te staan en verdient opvolging."
        if autonomy_level == "draft":
            return f"Bereid indien nuttig alvast een conceptreactie of beslisvoorstel voor bij '{subject}' van {sender}."
        return f"Neem een besluit of stuur een reactie op '{subject}' van {sender}."

    def _followup_event_action(title: str, start: str) -> str:
        if autonomy_level == "signal":
            return f"Signaal: {title} op {start} lijkt nog voorbereiding of afstemming te vragen."
        if autonomy_level == "draft":
            return f"Zet voor {title} op {start} alvast een korte prepnotitie, agenda of conceptvragen klaar."
        return f"Plan kort voorbereiding of afstemming voor {title} op {start}."

    opening_lines: list[str] = []
    if dutch:
        replied_count = len(replied_items)
        fyi_count = len(fyi_email_items)
        if open_email_items and prep_event_items:
            opening_lines.append(
                "Je open opvolging zit nu vooral in een combinatie van mails die nog om actie vragen en afspraken die nog voorbereiding kunnen gebruiken."
            )
        elif open_email_items:
            opening_lines.append(
                "Je grootste open loops zitten nu vooral in je inbox: er liggen nog een paar mails die waarschijnlijk om reactie of besluit vragen."
            )
        elif prep_event_items:
            opening_lines.append(
                "De meeste follow-updruk zit nu in je agenda: er staan afspraken aan te komen waar nog voorbereiding of afstemming voor nodig lijkt."
            )
        else:
            opening_lines.append(
                "Er springt geen grote achterstand uit, maar er zijn wel een paar signalen die je helpen om losse eindjes klein te houden."
            )
        if replied_count > 0 and fyi_count > 0:
            opening_lines.append(
                f"Daarnaast zijn er {replied_count} mail{'s' if replied_count != 1 else ''} die al beantwoord {'zijn' if replied_count != 1 else 'is'} "
                f"en {fyi_count} ter-info {'berichten' if fyi_count != 1 else 'bericht'} waar geen actie voor nodig is."
            )
        elif replied_count > 0:
            opening_lines.append(
                f"{replied_count} mail{'s zijn' if replied_count != 1 else ' is'} al beantwoord en hoef je niet opnieuw op te pakken."
            )
        elif fyi_count > 0:
            opening_lines.append(
                f"{fyi_count} {'berichten vragen' if fyi_count != 1 else 'bericht vraagt'} geen actie — ter info."
            )
    else:
        replied_count = len(replied_items)
        fyi_count = len(fyi_email_items)
        if open_email_items and prep_event_items:
            opening_lines.append(
                "Your main follow-up pressure currently comes from a mix of reply-needed mail and upcoming meetings that still need preparation."
            )
        elif open_email_items:
            opening_lines.append(
                "Most of the open loops right now appear to sit in your inbox: a few messages likely still need a reply or decision."
            )
        elif prep_event_items:
            opening_lines.append(
                "Most of the follow-up pressure right now appears to sit in your calendar: upcoming meetings still look like they need preparation or coordination."
            )
        else:
            opening_lines.append(
                "There is no obvious backlog right now, but there are still a few signals worth keeping small before they turn into loose ends."
            )
        if replied_count > 0 and fyi_count > 0:
            opening_lines.append(
                f"Additionally, {replied_count} message{'s have' if replied_count != 1 else ' has'} already been replied to, "
                f"and {fyi_count} {'are' if fyi_count != 1 else 'is'} FYI only with no action required."
            )
        elif replied_count > 0:
            opening_lines.append(
                f"{replied_count} message{'s have' if replied_count != 1 else ' has'} already been replied to — no further action needed there."
            )
        elif fyi_count > 0:
            opening_lines.append(
                f"{fyi_count} message{'s require' if fyi_count != 1 else ' requires'} no action — FYI only."
            )

    priority_blocks: list[list[str]] = []
    for score, reasons, record in open_email_items[:2]:
        subject = str(record.get("subject") or "(zonder onderwerp)").strip()
        sender = str(record.get("sender_label") or "onbekende afzender").strip()
        reason_text = ", ".join(_dedupe_preserve_order(reasons[:2])) or ("vraagt waarschijnlijk opvolging" if dutch else "likely needs follow-up")
        priority_blocks.append([
            f"- {'Open loop' if not dutch else 'Open loop'}: {subject}",
            f"  {'Afzender' if dutch else 'Sender'}: {sender}",
            f"  {'Waarom' if dutch else 'Why'}: {reason_text}.",
        ])
    for score, reasons, record in prep_event_items[:1]:
        title = str(record.get("title") or "(zonder titel)").strip()
        start = str(record.get("start") or "-").strip()
        reason_text = ", ".join(_dedupe_preserve_order(reasons[:2])) or ("kan voorbereiding vragen" if dutch else "may still need preparation")
        priority_blocks.append([
            f"- {'Prep needed' if not dutch else 'Prep nodig'}: {title}",
            f"  {'Wanneer' if dutch else 'When'}: {start}",
            f"  {'Waarom' if dutch else 'Why'}: {reason_text}.",
        ])

    action_lines: list[str] = []
    for _, _, record in prep_event_items[:1]:
        title = str(record.get("title") or "de afspraak").strip()
        start = str(record.get("start") or "-").strip()
        action_lines.append(f"{len(action_lines) + 1}. {_followup_event_action(title, start)}")
    for _, _, record in open_email_items[:2]:
        subject = str(record.get("subject") or "(zonder onderwerp)").strip()
        sender = str(record.get("sender_label") or "de afzender").strip()
        action_lines.append(f"{len(action_lines) + 1}. {_followup_email_action(subject, sender)}")
    if not action_lines and watch_email_items:
        record = watch_email_items[0][2]
        subject = str(record.get("subject") or "(zonder onderwerp)").strip()
        sender = str(record.get("sender_label") or "de afzender").strip()
        if autonomy_level == "signal":
            action_lines.append(f"1. Signaal: houd '{subject}' van {sender} nog even op je radar.")
        elif autonomy_level == "draft":
            action_lines.append(f"1. Check later of '{subject}' van {sender} een korte conceptreactie of besluitvoorstel vraagt.")
        else:
            action_lines.append(f"1. Check later of '{subject}' van {sender} nog een reactie of besluit vraagt.")
    if not action_lines:
        action_lines.append(
            "1. Er zijn geen harde open loops zichtbaar; houd alleen je inbox en eerstvolgende afspraak kort in beeld."
            if dutch else
            "1. There are no hard open loops visible right now; just keep your inbox and next meeting lightly in view."
        )

    reply_needed_lines = [
        f"- {str(item[2].get('subject') or '(zonder onderwerp)').strip()} | {str(item[2].get('sender_label') or ('onbekende afzender' if dutch else 'unknown sender')).strip()}"
        for item in open_email_items
    ]
    prep_needed_lines = [
        f"- **{str(item[2].get('title') or '(zonder titel)').strip()}** | {str(item[2].get('start') or '-').strip()}"
        for item in prep_event_items
    ]
    watch_lines = [
        f"- {str(item[2].get('subject') or '(zonder onderwerp)').strip()} | {str(item[2].get('sender_label') or ('onbekende afzender' if dutch else 'unknown sender')).strip()}"
        for item in watch_email_items
    ]
    fyi_lines = [
        f"- {str(item[2].get('subject') or '(zonder onderwerp)').strip()} | {str(item[2].get('sender_label') or ('onbekende afzender' if dutch else 'unknown sender')).strip()}"
        for item in fyi_email_items
    ]
    replied_lines = [
        f"- {str(item[2].get('subject') or '(zonder onderwerp)').strip()} | {str(item[2].get('sender_label') or ('onbekende afzender' if dutch else 'unknown sender')).strip()}"
        for item in replied_items[:4]
    ]

    action_section_title_nl = "Belangrijkste signalen" if autonomy_level == "signal" else "Aanbevolen opvolging"
    action_section_title_en = "Key signals" if autonomy_level == "signal" else "Recommended follow-up"

    if dutch:
        lines = ["**Follow-up overzicht**"]
        lines.extend(opening_lines)
        _append_section(lines, "Open loops", priority_blocks or [["- Geen duidelijke open loops met hoge urgentie."]])
        _append_section(lines, action_section_title_nl, action_lines)
        if reply_needed_lines:
            _append_section(lines, "Reactie of besluit nodig", reply_needed_lines)
        if prep_needed_lines:
            _append_section(lines, "Voorbereiding nodig", prep_needed_lines)
        if fyi_lines:
            _append_section(lines, "Ter info — geen actie nodig", fyi_lines)
        if replied_lines:
            _append_section(lines, "Al beantwoord — afgehandeld", replied_lines)
        if watch_lines:
            _append_section(lines, "In de gaten houden", watch_lines)
        if sources:
            _append_section(lines, "Bronnen", [f"- {', '.join(sources)}."])
        return "\n".join(lines).strip()

    lines = ["**Follow-up Brief**"]
    lines.extend(opening_lines)
    _append_section(lines, "Open loops", priority_blocks or [["- No clear high-urgency open loops right now."]])
    _append_section(lines, action_section_title_en, action_lines)
    if reply_needed_lines:
        _append_section(lines, "Reply or decision needed", reply_needed_lines)
    if prep_needed_lines:
        _append_section(lines, "Preparation needed", prep_needed_lines)
    if fyi_lines:
        _append_section(lines, "FYI only — no action needed", fyi_lines)
    if replied_lines:
        _append_section(lines, "Already replied — handled", replied_lines)
    if watch_lines:
        _append_section(lines, "Watchlist", watch_lines)
    if sources:
        _append_section(lines, "Sources", [f"- {', '.join(sources)}."])
    return "\n".join(lines).strip()