TOOL_NAME = "proactive_inbox_scan"
TOOL_DESCRIPTION = (
    "Proactive end-of-day inbox scanner. Fetches Outlook email + calendar, scores by urgency, "
    "and produces a FINISHED Dutch proactive briefing via the configured LLM. "
    "Output is a ready-to-present advisory — present directly without summarising or reformatting."
)
TOOL_ARGS = (
    '{"lookback_hours":{"type":"integer","default":72,"description":"Hours to scan back for emails"},'
    '"calendar_days":{"type":"integer","default":2,"description":"Days ahead to check calendar"},'
    '"max_emails_per_folder":{"type":"integer","default":40,"description":"Max emails to fetch per folder"},'
    '"score_threshold":{"type":"integer","default":25,"description":"Minimum score (0-100) to include an email"}}'
)

import re, json, requests
from pathlib import Path
from datetime import datetime, timedelta
from tools._std import tool_ok, tool_error

_ROOT = Path(__file__).resolve().parent.parent
_SETTINGS_FILE = _ROOT / "settings.json"
_USER_FILE = _ROOT / "user.md"
_MEMORY_DIR = _ROOT / "memory"
_OUTPUT_DIR = _ROOT / "output"

_URGENT_RE = re.compile(
    r"\b(spoed|urgent|asap|zo snel mogelijk|dringend|critical|immediately|"
    r"deadline|vandaag|today|actie vereist|action required|overdue|achterstallig|"
    r"factuur|invoice|betaling|payment|belangrijk|important|priority|prioriteit)\b",
    re.IGNORECASE,
)
_QUESTION_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|"
    r"do you|would you|need to|should we|invullen)\b",
    re.IGNORECASE,
)
_LOW_SIGNAL_RE = re.compile(
    r"\b(newsletter|digest|noreply|no-reply|notification|automated|unsubscribe|"
    r"nieuwsbrief|reclame|korting|sale|promo|promotion|aanbieding|deal|offer|"
    r"webshop|your order|bestelbevestiging|track your|volg je|delivered|"
    r"kinderpostzegels|picnic|eneco|bol\.com|coolblue|mediamarkt|thuisbezorgd)\b",
    re.IGNORECASE,
)
_MEETING_PREP_RE = re.compile(
    r"\b(review|demo|client|klant|board|interview|presentation|presentatie|"
    r"kickoff|1:1|one-on-one|steering|sync|standup|workshop|brainstorm)\b",
    re.IGNORECASE,
)

# ── Settings & LLM ───────────────────────────────────────────────────────────

def _load_settings() -> dict:
    try:
        return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
    except Exception:
        return {}


def _call_llm(prompt: str, system: str = "", max_tokens: int = 2000):
    """Route to main_model provider. Returns text or None if no LLM configured."""
    settings = _load_settings()
    model = (settings.get("main_model") or "").strip()
    if not model:
        return None
    try:
        if model.startswith("openai/") or model.startswith("nvidia/"):
            prefix = "openai/" if model.startswith("openai/") else "nvidia/"
            model_id = model[len(prefix):]
            key_name = "openai_api_key" if prefix == "openai/" else "nvidia_api_key"
            api_key = (settings.get(key_name) or "").strip()
            url = ("https://api.openai.com/v1/chat/completions" if prefix == "openai/"
                   else "https://integrate.api.nvidia.com/v1/chat/completions")
            if not api_key:
                return None
            msgs = ([{"role": "system", "content": system}] if system else [])
            msgs.append({"role": "user", "content": prompt})
            r = requests.post(url,
                              headers={"Authorization": f"Bearer {api_key}",
                                       "Content-Type": "application/json"},
                              json={"model": model_id, "messages": msgs,
                                    "max_tokens": max_tokens, "temperature": 0.6,
                                    "stream": False},
                              timeout=120)
            r.raise_for_status()
            return (r.json()["choices"][0]["message"].get("content") or "").strip()
        if model.startswith("gemini/"):
            model_id = model[len("gemini/"):]
            api_key = (settings.get("gemini_api_key") or "").strip()
            if not api_key:
                return None
            url = (f"https://generativelanguage.googleapis.com/v1beta/models/"
                   f"{model_id}:generateContent?key={api_key}")
            contents = []
            if system:
                contents += [{"role": "user", "parts": [{"text": system}]},
                              {"role": "model", "parts": [{"text": "Begrepen."}]}]
            contents.append({"role": "user", "parts": [{"text": prompt}]})
            r = requests.post(url, headers={"Content-Type": "application/json"},
                              json={"contents": contents,
                                    "generationConfig": {"temperature": 0.6,
                                                         "maxOutputTokens": max_tokens}},
                              timeout=120)
            r.raise_for_status()
            parts = (r.json().get("candidates", [{}])[0]
                     .get("content", {}).get("parts", []))
            return "".join(p.get("text", "") for p in parts).strip()
        if model.startswith("llamacpp/"):
            lurl = (settings.get("llamacpp_url") or "http://localhost:8080").rstrip("/")
            msgs = ([{"role": "system", "content": system}] if system else [])
            msgs.append({"role": "user", "content": prompt})
            r = requests.post(f"{lurl}/v1/chat/completions",
                              headers={"Content-Type": "application/json"},
                              json={"messages": msgs, "temperature": 0.6,
                                    "max_tokens": max_tokens, "stream": False},
                              timeout=180)
            r.raise_for_status()
            return (r.json()["choices"][0]["message"].get("content") or "").strip()
        # Ollama
        msgs = ([{"role": "system", "content": system}] if system else [])
        msgs.append({"role": "user", "content": prompt})
        r = requests.post("http://localhost:11434/api/chat",
                          json={"model": model, "messages": msgs, "stream": False,
                                "options": {"temperature": 0.6}},
                          timeout=180)
        r.raise_for_status()
        return (r.json().get("message") or {}).get("content", "").strip()
    except Exception as e:
        print(f"[proactive_scan] LLM error: {e}")
        return None


# ── Outlook helpers ───────────────────────────────────────────────────────────

def _dt_naive(com_dt) -> datetime:
    """Convert a COM datetime (pywintypes.datetime) to a naive Python datetime."""
    try:
        if hasattr(com_dt, "year"):
            return datetime(com_dt.year, com_dt.month, com_dt.day,
                            com_dt.hour, com_dt.minute, com_dt.second)
    except Exception:
        pass
    try:
        return datetime.fromisoformat(str(com_dt).replace("+00:00", ""))
    except Exception:
        return datetime.now()


def _safe_str(val, default: str = "") -> str:
    """Safely convert COM value to string."""
    try:
        return str(val or default).strip()
    except Exception:
        return default


def _score_email(subject: str, body: str, sender: str,
                 age_hours: float, unanswered: bool,
                 sent_subjects: set[str]) -> int:
    """Score an email 0-100 on priority. Newsletters/promos → 0."""
    combined = f"{subject} {body}".lower()
    sender_lower = sender.lower()

    # Low-signal filter (newsletters, promos)
    if _LOW_SIGNAL_RE.search(combined) or _LOW_SIGNAL_RE.search(sender_lower):
        return 0

    score = 30  # baseline

    # Urgency keywords
    if _URGENT_RE.search(combined):
        score += 25

    # Action request / question
    if _QUESTION_RE.search(combined):
        score += 15

    # Unanswered penalty bonus
    if unanswered:
        score += 15

    # Recency bonus (newer = higher)
    if age_hours < 4:
        score += 10
    elif age_hours < 12:
        score += 5
    elif age_hours > 48:
        score -= 10

    return max(0, min(100, score))


def _score_calendar_item(subject: str, body: str, attendees: str,
                         start: datetime, duration_min: int) -> int:
    """Score a calendar item 0-100 on preparation need."""
    combined = f"{subject} {body} {attendees}".lower()
    score = 20  # baseline

    if _MEETING_PREP_RE.search(combined):
        score += 30

    # External attendees boost
    att_count = len([a for a in attendees.split(";") if a.strip()]) if attendees else 0
    if att_count > 3:
        score += 15
    elif att_count > 1:
        score += 5

    # Duration boost
    if duration_min >= 60:
        score += 10
    elif duration_min >= 30:
        score += 5

    # Time proximity
    hours_until = max(0, (start - datetime.now()).total_seconds() / 3600)
    if hours_until < 4:
        score += 15
    elif hours_until < 12:
        score += 10
    elif hours_until < 24:
        score += 5

    return max(0, min(100, score))


def _fetch_outlook_emails(lookback_hours: int, max_per_folder: int) -> list[dict]:
    """Fetch recent emails from Outlook via COM. Returns list of email dicts."""
    try:
        import pythoncom
        import win32com.client
    except ImportError:
        return []

    emails: list[dict] = []
    cutoff = datetime.now() - timedelta(hours=lookback_hours)

    try:
        pythoncom.CoInitialize()
        try:
            outlook = win32com.client.Dispatch("Outlook.Application")
            ns = outlook.GetNamespace("MAPI")

            # Scan Inbox and common folders
            folder_ids = {"Inbox": 6, "Sent Items": 5}
            for folder_name, fid in folder_ids.items():
                try:
                    folder = ns.GetDefaultFolder(fid)
                    _scan_folder(folder, folder_name, emails, cutoff, max_per_folder)
                except Exception:
                    pass

            # Also scan subfolders of Inbox
            try:
                inbox = ns.GetDefaultFolder(6)
                for i in range(1, inbox.Folders.Count + 1):
                    try:
                        subfolder = inbox.Folders.Item(i)
                        name = f"Inbox/{_safe_str(subfolder.Name)}"
                        _scan_folder(subfolder, name, emails, cutoff, max_per_folder)
                    except Exception:
                        pass
            except Exception:
                pass
        finally:
            pythoncom.CoUninitialize()

    except Exception as e:
        print(f"[proactive_scan] Outlook error: {e}")

    return emails


def _scan_folder(folder, folder_name: str, emails: list[dict],
                 cutoff: datetime, max_items: int):
    """Scan a single Outlook folder and append email dicts."""
    try:
        items = folder.Items
        items.Sort("[ReceivedTime]", True)
        total = min(int(items.Count), max_items * 3)

        collected = 0
        for idx in range(1, total + 1):
            if collected >= max_items:
                break
            try:
                msg = items.Item(idx)
                msg_class = _safe_str(getattr(msg, "MessageClass", "IPM.Note"))
                if not msg_class.startswith("IPM.Note"):
                    continue

                received_dt = _dt_naive(msg.ReceivedTime)
                if received_dt < cutoff:
                    break

                age_hours = (datetime.now() - received_dt).total_seconds() / 3600

                sender_addr = _safe_str(getattr(msg, "SenderEmailAddress", ""))
                sender_name = _safe_str(getattr(msg, "SenderName", ""))
                subject = _safe_str(msg.Subject)
                body = _safe_str(msg.Body)[:500]
                entry_id = _safe_str(getattr(msg, "EntryID", ""))
                conv_id = _safe_str(getattr(msg, "ConversationID", ""))
                unread = bool(getattr(msg, "UnRead", False))

                emails.append({
                    "entry_id": entry_id,
                    "conv_id": conv_id,
                    "sender": sender_addr,
                    "sender_name": sender_name,
                    "subject": subject,
                    "body": body,
                    "received_dt": received_dt,
                    "age_hours": age_hours,
                    "folder": folder_name,
                    "unread": unread,
                })
                collected += 1
            except Exception:
                pass
    except Exception:
        pass


def _fetch_sent_subjects(lookback_hours: int) -> set[str]:
    """Fetch subjects of sent emails for reply detection."""
    subjects: set[str] = set()
    try:
        import pythoncom
        import win32com.client
        pythoncom.CoInitialize()
        try:
            outlook = win32com.client.Dispatch("Outlook.Application")
            ns = outlook.GetNamespace("MAPI")
            sent = ns.GetDefaultFolder(5)  # Sent Items
            items = sent.Items
            items.Sort("[SentOn]", True)

            cutoff = datetime.now() - timedelta(hours=lookback_hours)
            total = min(int(items.Count), 200)

            for idx in range(1, total + 1):
                try:
                    msg = items.Item(idx)
                    sent_dt = _dt_naive(msg.SentOn)
                    if sent_dt < cutoff:
                        break
                    subj = _safe_str(msg.Subject).lower().strip()
                    # Normalize RE:/FW: prefixes
                    subj = re.sub(r"^(re|fw|fwd)\s*:\s*", "", subj, flags=re.IGNORECASE).strip()
                    if subj:
                        subjects.add(subj)
                except Exception:
                    pass
        finally:
            pythoncom.CoUninitialize()
    except Exception:
        pass
    return subjects


def _is_unanswered(email_dict: dict, sent_subjects: set[str]) -> bool:
    """Check if an email has NOT been replied to."""
    subj = email_dict["subject"].lower().strip()
    subj_norm = re.sub(r"^(re|fw|fwd)\s*:\s*", "", subj, flags=re.IGNORECASE).strip()
    return subj_norm not in sent_subjects


def _fetch_calendar(calendar_days: int) -> list[dict]:
    """Fetch upcoming calendar items from Outlook."""
    items_out: list[dict] = []
    try:
        import pythoncom
        import win32com.client
        pythoncom.CoInitialize()
        try:
            outlook = win32com.client.Dispatch("Outlook.Application")
            ns = outlook.GetNamespace("MAPI")
            cal = ns.GetDefaultFolder(9)
            items = cal.Items
            items.IncludeRecurrences = True
            items.Sort("[Start]")

            now = datetime.now()
            end = now + timedelta(days=calendar_days)

            # Build restriction using locale-safe formatting
            import ctypes

            class SYSTEMTIME(ctypes.Structure):
                _fields_ = [
                    ("wYear", ctypes.c_ushort), ("wMonth", ctypes.c_ushort),
                    ("wDayOfWeek", ctypes.c_ushort), ("wDay", ctypes.c_ushort),
                    ("wHour", ctypes.c_ushort), ("wMinute", ctypes.c_ushort),
                    ("wSecond", ctypes.c_ushort), ("wMilliseconds", ctypes.c_ushort),
                ]

            def _fmt_dt(dt):
                st = SYSTEMTIME(wYear=dt.year, wMonth=dt.month, wDay=dt.day)
                buf = ctypes.create_unicode_buffer(80)
                ctypes.windll.kernel32.GetDateFormatW(0x0400, 1, ctypes.byref(st), None, buf, 80)
                return f"{buf.value} {dt.hour:02d}:{dt.minute:02d}"

            restriction = (
                f"[Start] >= '{_fmt_dt(now)}' AND "
                f"[Start] <= '{_fmt_dt(end)}'"
            )
            restricted = items.Restrict(restriction)

            for item in restricted:
                try:
                    start = _dt_naive(item.Start)
                    end_dt = _dt_naive(item.End)
                    duration = max(0, int((end_dt - start).total_seconds() / 60))
                    attendee_str = _safe_str(getattr(item, "RequiredAttendees", ""))
                    opt = _safe_str(getattr(item, "OptionalAttendees", ""))
                    if opt:
                        attendee_str = f"{attendee_str}; {opt}" if attendee_str else opt

                    items_out.append({
                        "subject": _safe_str(item.Subject),
                        "body": _safe_str(getattr(item, "Body", ""))[:300],
                        "attendees": attendee_str,
                        "start": start,
                        "duration_min": duration,
                        "location": _safe_str(getattr(item, "Location", "")),
                    })
                except Exception:
                    pass
        finally:
            pythoncom.CoUninitialize()
    except Exception as e:
        print(f"[proactive_scan] Calendar error: {e}")
    return items_out


def _read_user_profile() -> str:
    """Read user.md for context."""
    try:
        return _USER_FILE.read_text(encoding="utf-8")[:500]
    except Exception:
        return ""


def _read_recent_memory() -> str:
    """Read recent memory entries for context."""
    try:
        index_file = _MEMORY_DIR / "_index.json"
        if not index_file.exists():
            return ""
        index = json.loads(index_file.read_text(encoding="utf-8"))
        if isinstance(index, dict):
            entries = sorted(index.values(),
                             key=lambda e: e.get("created_at", ""), reverse=True)[:5]
        elif isinstance(index, list):
            entries = sorted(index,
                             key=lambda e: e.get("created_at", ""), reverse=True)[:5]
        else:
            return ""
        lines = []
        for entry in entries:
            goal = entry.get("goal", "")
            summary = entry.get("summary", "")
            if goal:
                lines.append(f"- {goal}" + (f": {summary[:100]}" if summary else ""))
        return "\n".join(lines)
    except Exception:
        return ""


# ── Main runner ───────────────────────────────────────────────────────────────

def run(args: dict) -> str:
    lookback_hours = int(args.get("lookback_hours") or 72)
    calendar_days = int(args.get("calendar_days") or 2)
    max_per_folder = int(args.get("max_emails_per_folder") or 40)
    score_threshold = int(args.get("score_threshold") or 25)

    now = datetime.now()
    today_str = f"{now.day} {now.strftime('%B')} {now.year}"

    # ── Step 1: Fetch sent items ──────────────────────────────────────────────
    sent_subjects = _fetch_sent_subjects(lookback_hours)

    # ── Step 2: Fetch inbox emails ────────────────────────────────────────────
    emails = _fetch_outlook_emails(lookback_hours, max_per_folder)

    # ── Step 3: Score each email ──────────────────────────────────────────────
    scored_emails = []
    for em in emails:
        unanswered = _is_unanswered(em, sent_subjects)
        score = _score_email(
            em["subject"], em["body"], em["sender"],
            em["age_hours"], unanswered, sent_subjects,
        )
        if score >= score_threshold:
            scored_emails.append({**em, "score": score, "unanswered": unanswered})

    seen_convs: dict[str, dict] = {}
    for em in scored_emails:
        cid = em["conv_id"] or em["entry_id"]
        if cid not in seen_convs or em["score"] > seen_convs[cid]["score"]:
            seen_convs[cid] = em
    top_emails = sorted(seen_convs.values(), key=lambda e: e["score"], reverse=True)[:10]

    # ── Step 4: Fetch calendar items ──────────────────────────────────────────
    cal_items = _fetch_calendar(calendar_days)
    scored_cal = []
    for item in cal_items:
        prep_score = _score_calendar_item(
            item["subject"], item["body"], item["attendees"],
            item["start"], item["duration_min"],
        )
        scored_cal.append({**item, "prep_score": prep_score})
    top_cal = sorted(scored_cal, key=lambda c: c["prep_score"], reverse=True)[:6]

    # ── Step 5: Read memory ───────────────────────────────────────────────────
    memory_context = _read_recent_memory()

    # ── Step 6: Build LLM prompt ──────────────────────────────────────────────
    email_lines = []
    for em in top_emails:
        flags = []
        if em.get("unread"):
            flags.append("ONGELEZEN")
        if em["unanswered"]:
            flags.append("GEEN REACTIE GESTUURD")
        flag_str = " [" + " | ".join(flags) + "]" if flags else ""
        age_h = int(em["age_hours"])
        email_lines.append(
            f"- Score {em['score']}/100{flag_str}\n"
            f"  Van: {em['sender_name'] or em['sender']}\n"
            f"  Onderwerp: {em['subject']}\n"
            f"  Leeftijd: {age_h}u | Map: {em['folder']}\n"
            f"  Inhoud: {em['body'][:300]}"
        )
    email_block = (
        "\n\n".join(email_lines) if email_lines
        else (f"Geen prioritaire e-mails (alle {len(emails)} gescande mails waren "
              f"nieuwsbrieven/promoties of scoorden onder drempel {score_threshold}).")
    )

    cal_lines = []
    for item in top_cal:
        s = item["start"]
        start_str = f"{s.day} {s.strftime('%b')} {s.strftime('%H:%M')}"
        cal_lines.append(
            f"- Prep-score {item['prep_score']}/100: {item['subject']}\n"
            f"  Start: {start_str} | Duur: {item['duration_min']} min\n"
            f"  Locatie: {item['location'] or '-'} | Aanwezigen: {item['attendees'][:150] or '-'}"
        )
    cal_block = (
        "\n\n".join(cal_lines) if cal_lines
        else f"Geen agenda-items in de komende {calendar_days} dagen."
    )

    llm_prompt = (
        f"Je bent de proactieve co-pilot van Emile Kramers. Vandaag is het {today_str}.\n"
        f"Dit is de eindedag-scan (circa 17:00). Geef een ACTIEGERICHTE briefing in het NEDERLANDS.\n\n"
        f"## Gescoorde e-mails — afgelopen {lookback_hours} uur\n"
        f"(Alleen e-mails die een actiedrempel haalden; nieuwsbrieven en reclame zijn gefilterd)\n"
        f"{email_block}\n\n"
        f"## Agenda — komende {calendar_days} dag(en)\n"
        f"{cal_block}\n\n"
        f"## Geheugen / open punten\n"
        f"{memory_context[:500] or '(geen)'}\n\n"
        f"---\n"
        f"Geef de briefing in deze structuur (bondig, max 400 woorden):\n\n"
        f"### Top 3 acties\n"
        f"(maximaal 3 concrete stappen; een zin per actie waarom urgent)\n\n"
        f"### E-mails die een reactie verwachten\n"
        f"(per e-mail: afzender, onderwerp, aanbevolen toon — alleen als actie vereist is)\n\n"
        f"### Vergaderingen met voorbereiding\n"
        f"(wat lezen/weten voor de meeting — sla over als er niets relevants is)\n\n"
        f"### Observatie\n"
        f"(een patroon, risico of kans die je opvalt op basis van bovenstaande data)\n\n"
        f"Als er geen prioritaire acties zijn, zeg dat dan eerlijk en geef aan waarom de inbox rustig is."
    )

    system = (
        "Je bent een proactieve persoonlijke assistent die dagelijkse e-mail en agenda analyseert "
        "voor Emile Kramers. Wees scherp, bondig en prioriteer wat echt telt. Schrijf in het Nederlands."
    )

    # ── Step 7: Generate briefing via LLM ────────────────────────────────────
    briefing = _call_llm(llm_prompt, system=system)

    if not briefing:
        # No LLM configured — plain-text fallback (still actionable, not a raw list)
        lines = [
            f"## Proactieve Scan — {today_str}\n",
            f"_{len(emails)} e-mails gescand - {len(top_emails)} prioritair - {len(cal_items)} agenda-items_\n",
        ]
        if top_emails:
            lines.append("### Prioritaire e-mails")
            for em in top_emails:
                flag = " - geen reactie" if em["unanswered"] else ""
                lines.append(
                    f"- **{em['sender_name'] or em['sender']}** — {em['subject']}"
                    f"{flag} (score {em['score']}/100, {int(em['age_hours'])}u oud)"
                )
        else:
            lines.append("Inbox ziet er rustig uit — geen prioritaire e-mails gevonden.")
        if top_cal:
            lines.append("\n### Agenda")
            for item in top_cal:
                s = item["start"]
                lines.append(
                    f"- {item['subject']} "
                    f"({s.day} {s.strftime('%b')} {s.strftime('%H:%M')}, "
                    f"prep-score {item['prep_score']}/100)"
                )
        briefing = "\n".join(lines)

    # ── Step 8: Save & return ─────────────────────────────────────────────────
    try:
        _OUTPUT_DIR.mkdir(exist_ok=True)
        ts = f"{now.year}{now.month:02d}{now.day:02d}_{now.hour:02d}{now.minute:02d}"
        (_OUTPUT_DIR / f"proactive_briefing_{ts}.md").write_text(briefing, encoding="utf-8")
    except Exception:
        pass

    return tool_ok(
        {"emails_scanned": len(emails), "prioritized": len(top_emails), "calendar_items": len(cal_items)},
        briefing
    )
