"""
Integrations: Slack Socket Mode bot, Google OAuth routes, Outlook test,
server-side schedules, folder picker, file upload.

Uses FastAPI APIRouter so routes can be included in the main app.
"""

import asyncio
import base64
import io
import json
import mimetypes
import re
import threading
import time
import uuid
from datetime import datetime, timedelta
from pathlib import Path

import requests
from fastapi import APIRouter, Request, UploadFile, File
from fastapi.responses import HTMLResponse
from starlette.responses import JSONResponse

from backend.config import (
    GOOGLE_API_SUPPORT, GOOGLE_SCOPES, GOOGLE_TOKEN_FILE,
    OUTLOOK_SUPPORT, SLACK_BOLT_SUPPORT,
    PDF_SUPPORT,
    TELEGRAM_SUPPORT, WHATSAPP_SUPPORT,
    MICROSOFT_TOKEN_FILE, MICROSOFT_AUTH_URL, MICROSOFT_TOKEN_URL, MICROSOFT_SCOPES,
    MICROSOFT_GRAPH_URL,
    _load_settings, _google_get_credentials, _google_has_all_scopes, _google_has_calendar_write_scope,
    _google_build_flow, _google_service,
    _microsoft_get_token, _microsoft_graph_headers,
    _detect_onedrive_local_path,
    _outlook_session,
    _load_schedules, _save_schedules,
    OUTPUT_DIR,
    WORKSPACE_ROOT,
)
from backend.local_time import utc_now_iso_z
from backend.memory import (
    _load_session, _save_session, _generate_session_followup,
)
from backend.content_extraction import extract_uploaded_bytes

# Conditional imports — presence already checked by feature flags
try:
    from backend.config import pypdf
except ImportError:
    pypdf = None

try:
    from slack_bolt import App as SlackBoltApp
    from slack_bolt.adapter.socket_mode import SocketModeHandler as SlackSocketHandler
except ImportError:
    SlackBoltApp = None  # type: ignore
    SlackSocketHandler = None  # type: ignore

try:
    from telegram import Update as TelegramUpdate
    from telegram.ext import ApplicationBuilder as TelegramAppBuilder, MessageHandler as TelegramMsgHandler, CommandHandler as TelegramCmdHandler, filters as telegram_filters, ContextTypes
except ImportError:
    TelegramUpdate = None  # type: ignore
    TelegramAppBuilder = None  # type: ignore

router = APIRouter()

# ── Module-level state ────────────────────────────────────────────────────────
_slack_handler: "SlackSocketHandler | None" = None
_slack_handler_lock = threading.Lock()
_telegram_app = None
_telegram_thread: threading.Thread | None = None
_main_loop: asyncio.AbstractEventLoop | None = None   # set by main.py at startup
_slack_channel_last_session: dict[str, str] = {}
_telegram_chat_last_session: dict[int, str] = {}
_whatsapp_chat_last_session: dict[str, str] = {}

_WA_MEDIA_DIR_REL = Path("attachments") / "whatsapp"
_CHAT_UPLOAD_DIR_REL = Path("attachments") / "chat_uploads"
_WA_MEDIA_KIND_MIME_PREFIX = {
    "image": "image/",
    "document": "application/",
    "video": "video/",
    "audio": "audio/",
}


def _sanitize_upload_filename(name: str) -> str:
    raw = Path(str(name or "upload")).name.strip()
    cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-")
    return (cleaned or "upload")[:120]


def _store_chat_upload(data: bytes, filename: str) -> str:
    safe_name = _sanitize_upload_filename(filename)
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    rel_path = _CHAT_UPLOAD_DIR_REL / f"{stamp}-{uuid.uuid4().hex[:8]}-{safe_name}"
    abs_path = (WORKSPACE_ROOT / rel_path).resolve()
    abs_path.parent.mkdir(parents=True, exist_ok=True)
    abs_path.write_bytes(data)
    return rel_path.as_posix()


def _format_uploaded_image_context(filename: str, mime_type: str, stored_path: str, size_bytes: int) -> str:
    return "\n".join([
        f"[ATTACHED IMAGE: {filename}]",
        f"Stored path: {stored_path}",
        f"Mime: {mime_type or 'image/*'}",
        f"Size: {size_bytes} bytes",
        "Use describe_image with the stored path above for visual analysis, comparison, grouping, or OCR.",
    ])


# ── Helper: get manager & run_orchestrator from main (breaks circular import) ─
def _get_main_refs():
    from backend.main import manager, run_orchestrator
    return manager, run_orchestrator


def _copilot_autonomy_summary(level: str) -> str:
    value = str(level or "suggest").strip().lower()
    if value == "draft":
        return "can prepare drafts, but will not send or modify anything automatically"
    if value == "signal":
        return "focuses on signals only"
    return "suggests concrete next actions"


def _copilot_setting_text(settings: dict, key: str, legacy_key: str = "") -> str:
    source_key = key if key in settings or not legacy_key else legacy_key
    return str(settings.get(source_key) or "").strip()


def _build_copilot_morning_brief_goal(settings: dict) -> str:
    sources: list[str] = []
    if settings.get("outlook_enabled"):
        sources.append("Outlook email and calendar")
    if settings.get("google_enabled"):
        sources.append("Gmail and Google Calendar")
    source_line = ", ".join(sources) if sources else "available email and calendar integrations"
    autonomy_level = str(settings.get("copilot_autonomy_level") or "suggest").strip().lower() or "suggest"
    if autonomy_level == "draft":
        autonomy_line = "You may prepare draft replies or preparation outlines, but do not send or modify anything automatically."
    elif autonomy_level == "signal":
        autonomy_line = "Only signal what matters. Do not prepare drafts unless explicitly asked."
    else:
        autonomy_line = "Suggest concrete next actions, but do not send or modify anything automatically."
    instruction_line = _copilot_setting_text(settings, "copilot_morning_brief_instructions", "copilot_instructions")
    return "\n".join([
        "Co-Pilot Morning Brief for today.",
        f"Use {source_line} as the primary sources.",
        "Deliver an advice-first morning brief, not an exhaustive inventory.",
        "Start with the 2-3 things that most deserve attention today and explain why they matter.",
        "Then give the recommended action order for this morning.",
        "Only after that add short supporting bullets for reply-likely emails, meetings needing preparation, and watch items.",
        "Avoid long lists and avoid repeating every raw mail or calendar item.",
        autonomy_line,
        f"Extra user instructions: {instruction_line or 'none.'}",
    ])


def _build_copilot_followup_brief_goal(settings: dict) -> str:
    sources: list[str] = []
    if settings.get("outlook_enabled"):
        sources.append("Outlook email and calendar")
    if settings.get("google_enabled"):
        sources.append("Gmail and Google Calendar")
    source_line = ", ".join(sources) if sources else "available email and calendar integrations"
    autonomy_level = str(settings.get("copilot_autonomy_level") or "suggest").strip().lower() or "suggest"
    if autonomy_level == "draft":
        autonomy_line = "You may prepare draft replies or preparation outlines, but do not send or modify anything automatically."
    elif autonomy_level == "signal":
        autonomy_line = "Only signal what matters. Do not prepare drafts unless explicitly asked."
    else:
        autonomy_line = "Suggest concrete next actions, but do not send or modify anything automatically."
    instruction_line = _copilot_setting_text(settings, "copilot_followup_instructions", "copilot_instructions")
    return "\n".join([
        "Co-Pilot Follow-up Brief for today.",
        f"Use {source_line} as the primary sources.",
        "Deliver a follow-up-first brief focused on open loops, pending replies, decisions, and meeting preparation.",
        "Start with the 2-3 items most likely to stall progress if ignored.",
        "Then group the remaining items into reply-needed, prep-needed, and watchlist signals.",
        "Avoid exhaustive lists and avoid repeating every raw mail or calendar item.",
        autonomy_line,
        f"Extra user instructions: {instruction_line or 'none.'}",
    ])


def _channel_copilot_catalog(settings: dict) -> list[dict]:
    copilot_enabled = bool(settings.get("copilot_enabled"))
    main_model = str(settings.get("main_model") or "").strip()
    morning_brief_enabled = bool(settings.get("copilot_morning_brief_enabled"))
    morning_brief_time = _copilot_setting_text(settings, "copilot_morning_brief_time", "copilot_briefing_time") or "08:30"
    followup_enabled = bool(settings.get("copilot_followup_enabled"))
    followup_time = _copilot_setting_text(settings, "copilot_followup_time") or "11:30"

    if not copilot_enabled:
        status = "disabled"
        reason = "Co-Pilot is off in Settings."
    elif not main_model:
        status = "not_ready"
        reason = "Main Model is not configured."
    else:
        status = "ready"
        reason = "Ready to run now."

    return [{
        "id": "morning_brief",
        "label": "Co-Pilot Morning Brief",
        "aliases": {"morning brief", "morning_brief", "brief", "daily brief", "morning"},
        "status": status,
        "reason": reason,
        "scheduled": morning_brief_enabled,
        "briefing_time": morning_brief_time,
        "goal": _build_copilot_morning_brief_goal(settings),
        "autonomy_summary": _copilot_autonomy_summary(settings.get("copilot_autonomy_level") or "suggest"),
    }, {
        "id": "follow_up",
        "label": "Co-Pilot Follow-up",
        "aliases": {"follow up", "follow-up", "followup", "open loops", "pending replies", "follow_up"},
        "status": status,
        "reason": reason,
        "scheduled": followup_enabled,
        "briefing_time": followup_time,
        "goal": _build_copilot_followup_brief_goal(settings),
        "autonomy_summary": _copilot_autonomy_summary(settings.get("copilot_autonomy_level") or "suggest"),
    }]


def _format_channel_copilot_list(settings: dict, start_example: str) -> str:
    lines = ["🤖 Available Co-Pilots:"]
    for item in _channel_copilot_catalog(settings):
        schedule_note = f" Scheduled daily at {item['briefing_time']}." if item.get("scheduled") and item.get("briefing_time") else ""
        lines.append(
            f"• {item['label']} (`{item['id']}`) — {item['reason']} {item['autonomy_summary']}.{schedule_note}".strip()
        )
    lines.append(f"Start one with: `{start_example}`")
    return "\n".join(lines)


def _parse_channel_copilot_command(text: str, settings: dict, start_example: str) -> dict | None:
    raw = str(text or "").strip()
    lower = raw.lower().strip()
    normalized = re.sub(r"^/", "", lower)
    normalized = re.sub(r"\bco-pilot\b", "copilot", normalized)

    if normalized in {"copilots", "list copilots", "copilot list", "copilot ls", "list copilot", "copilot", "co-pilot list", "list co-pilot", "co-pilot ls"}:
        return {"action": "list", "message": _format_channel_copilot_list(settings, start_example)}

    start_match = re.match(r"^(?:copilot\s+(?:start\s+)?|start\s+copilot\s+|run\s+copilot\s+)(.+)$", normalized)
    if not start_match:
        return None

    target = start_match.group(1).strip(" :-")
    if not target:
        return {"action": "error", "message": f"⚠️ Usage: `{start_example}` or `co-pilot list`"}

    for item in _channel_copilot_catalog(settings):
        aliases = set(item.get("aliases") or [])
        aliases.add(str(item.get("id") or ""))
        if target in aliases:
            if item.get("status") != "ready":
                return {"action": "error", "message": f"⚠️ {item['label']} is not ready: {item['reason']}"}
            return {
                "action": "start",
                "copilot_id": item["id"],
                "label": item["label"],
                "goal": item["goal"],
                "message": f"🤖 Starting {item['label']}... Updates will follow here.",
            }

    return {
        "action": "error",
        "message": "⚠️ Unknown Co-Pilot. Type `co-pilot list` to see what is available.",
    }


def _channel_report_chunks(report: str, *, chunk_size: int = 4000) -> list[str]:
    clean = str(report or "").strip()
    if not clean:
        return []
    return [clean[i:i + chunk_size] for i in range(0, len(clean), chunk_size)]


# ── Channel text formatting ──────────────────────────────────────────────────

def _format_for_whatsapp(text: str) -> str:
    """Convert markdown text to WhatsApp-compatible formatting.

    WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```monospace```.
    """
    out = str(text or "")
    # Tables FIRST (before markdown conversions destroy pipe structure)
    out = _format_table_plain(out)
    # Remove HTML tags that might leak through
    out = re.sub(r"<br\s*/?>", "\n", out, flags=re.IGNORECASE)
    out = re.sub(r"<[^>]+>", "", out)
    # Headers → bold lines (## Title → *Title*)
    out = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", out, flags=re.MULTILINE)
    # Bold: **text** or __text__ → *text*
    out = re.sub(r"\*\*(.+?)\*\*", r"*\1*", out)
    out = re.sub(r"__(.+?)__", r"*\1*", out)
    # Strikethrough: ~~text~~ → ~text~
    out = re.sub(r"~~(.+?)~~", r"~\1~", out)
    # Code blocks: ```lang\ncode\n``` → ```code```
    out = re.sub(r"```\w*\n", "```\n", out)
    # Inline code: `text` → ```text```  (WhatsApp monospace)
    out = re.sub(r"(?<!`)``(?!`)(.+?)(?<!`)``(?!`)", r"```\1```", out)
    # Horizontal rules → thin unicode line
    out = re.sub(r"^-{3,}$", "─────────────", out, flags=re.MULTILINE)
    out = re.sub(r"^\*{3,}$", "─────────────", out, flags=re.MULTILINE)
    out = re.sub(r"^_{3,}$", "─────────────", out, flags=re.MULTILINE)
    # Links: [text](url) → text (url)
    out = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", out)
    # Images: ![alt](url) → remove
    out = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", out)
    # Collapse 3+ blank lines to 2
    out = re.sub(r"\n{3,}", "\n\n", out)
    return out.strip()


def _format_for_slack(text: str) -> str:
    """Convert markdown text to Slack mrkdwn formatting.

    Slack supports: *bold*, _italic_, ~strikethrough~, `code`, ```code blocks```,
    >blockquote. Links: <url|text>.
    """
    out = str(text or "")
    # Tables FIRST
    out = _format_table_plain(out)
    out = re.sub(r"<br\s*/?>", "\n", out, flags=re.IGNORECASE)
    out = re.sub(r"<[^>]+>", "", out)
    # Headers → bold
    out = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", out, flags=re.MULTILINE)
    # Bold: **text** → *text* (slack uses single *)
    out = re.sub(r"\*\*(.+?)\*\*", r"*\1*", out)
    out = re.sub(r"__(.+?)__", r"*\1*", out)
    # Strikethrough
    out = re.sub(r"~~(.+?)~~", r"~\1~", out)
    # Horizontal rules
    out = re.sub(r"^-{3,}$", "─────────────", out, flags=re.MULTILINE)
    out = re.sub(r"^\*{3,}$", "─────────────", out, flags=re.MULTILINE)
    out = re.sub(r"^_{3,}$", "─────────────", out, flags=re.MULTILINE)
    # Links
    out = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"<\2|\1>", out)
    out = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", out)
    out = re.sub(r"\n{3,}", "\n\n", out)
    return out.strip()


def _format_for_telegram(text: str) -> str:
    """Convert markdown to clean plain text for Telegram.

    We send as plain text (no parse_mode) to avoid escaping headaches.
    Bold/italic are approximated with unicode or kept simple.
    """
    out = str(text or "")
    # Tables FIRST
    out = _format_table_plain(out)
    out = re.sub(r"<br\s*/?>", "\n", out, flags=re.IGNORECASE)
    out = re.sub(r"<[^>]+>", "", out)
    # Headers → prefix marker
    out = re.sub(r"^#{1,6}\s+(.+)$", r"▸ \1", out, flags=re.MULTILINE)
    # Bold: **text** → text (plain)
    out = re.sub(r"\*\*(.+?)\*\*", r"\1", out)
    out = re.sub(r"__(.+?)__", r"\1", out)
    # Strikethrough
    out = re.sub(r"~~(.+?)~~", r"\1", out)
    # Horizontal rules
    out = re.sub(r"^-{3,}$", "─────────────", out, flags=re.MULTILINE)
    out = re.sub(r"^\*{3,}$", "─────────────", out, flags=re.MULTILINE)
    out = re.sub(r"^_{3,}$", "─────────────", out, flags=re.MULTILINE)
    # Links
    out = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", out)
    out = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", out)
    out = re.sub(r"\n{3,}", "\n\n", out)
    return out.strip()


def _format_table_plain(text: str) -> str:
    """Convert markdown pipe-tables to a compact channel-friendly layout.

    Handles two table dialects:
    - Standard GFM:  | Col | Col | Col |
    - Space-pipe:    Col | Col | Col

    Wide tables (5+ visible columns) → stacked card layout.
    Narrow tables (≤4 visible columns) → aligned columns.
    Hidden columns (_message_id, _provider, _event_id, _start_iso, _end_iso)
    are stripped automatically.
    """
    _HIDDEN = {"message_id", "provider", "event_id", "start_iso", "end_iso"}
    _SEP = object()  # sentinel for separator rows

    lines = text.split("\n")
    result: list[str] = []
    table_headers: list[str] = []
    table_rows: list[list[str]] = []

    def _is_separator(s: str) -> bool:
        clean = s.replace("|", " ").strip()
        return bool(clean) and bool(re.match(r"^[\s\-:|]+$", clean))

    def _parse_pipe_row(stripped: str):
        """Return list[str] for data rows, _SEP for separator rows, None otherwise."""
        # Skip list items (bullet/numbered) — they may contain | as inline separators
        if re.match(r"^[-*+]\s", stripped) or re.match(r"^\d+\.\s", stripped):
            return None
        # Standard GFM: |col|col|col|
        if re.match(r"^\|.*\|$", stripped):
            if _is_separator(stripped):
                return _SEP
            return [c.strip() for c in stripped.strip("|").split("|")]
        # Space-pipe dialect: col | col | col  or  --- | --- | ---
        if " | " in stripped or stripped.startswith("--- |") or stripped.startswith("# |"):
            if _is_separator(stripped):
                return _SEP
            return [c.strip() for c in stripped.split("|")]
        return None

    def _visible_indices(headers):
        vis = [i for i, h in enumerate(headers)
               if h.strip().strip("_").lower() not in _HIDDEN]
        return vis if vis else list(range(len(headers)))

    def _flush_table():
        if not table_headers and not table_rows:
            return
        headers = table_headers or []
        vis = _visible_indices(headers) if headers else list(range(
            max((len(r) for r in table_rows), default=0)))
        v_headers = [headers[i] for i in vis] if headers else []
        v_rows = [[r[i] if i < len(r) else "" for i in vis] for r in table_rows]

        if len(v_headers) > 4 and v_rows:
            # Stacked card layout (phone-friendly for wide tables)
            for ri, row in enumerate(v_rows):
                if ri > 0:
                    result.append("")
                for ci, hdr in enumerate(v_headers):
                    val = row[ci] if ci < len(row) else ""
                    if not val or val == "-":
                        continue
                    if ci == 0 and hdr.strip() == "#":
                        result.append(f"━ {val} ━")
                    else:
                        result.append(f"  {hdr}: {val}")
        else:
            # Compact aligned columns (narrow tables)
            all_rows = ([v_headers] + v_rows) if v_headers else v_rows
            if not all_rows:
                return
            col_count = max(len(r) for r in all_rows)
            widths = [0] * col_count
            for row in all_rows:
                for i, cell in enumerate(row):
                    if i < col_count:
                        widths[i] = max(widths[i], len(cell))
            for ri, row in enumerate(all_rows):
                cells = [
                    (row[i] if i < len(row) else "").ljust(widths[i])
                    for i in range(col_count)
                ]
                result.append("  ".join(cells).rstrip())
                if ri == 0 and v_headers:
                    result.append("  ".join("─" * w for w in widths))
        table_headers.clear()
        table_rows.clear()

    for line in lines:
        stripped = line.strip()
        parsed = _parse_pipe_row(stripped)
        if parsed is _SEP:
            continue  # skip separator rows, don't flush
        if parsed is not None:
            if not table_headers:
                table_headers = parsed
            else:
                table_rows.append(parsed)
        else:
            _flush_table()
            result.append(line)
    _flush_table()
    return "\n".join(result)


def _smart_channel_chunks(text: str, *, chunk_size: int = 4000) -> list[str]:
    """Split text into chunks at paragraph boundaries, respecting chunk_size."""
    clean = str(text or "").strip()
    if not clean:
        return []
    if len(clean) <= chunk_size:
        return [clean]

    chunks: list[str] = []
    current = ""
    # Split on double-newline (paragraph boundary)
    paragraphs = clean.split("\n\n")
    for para in paragraphs:
        candidate = (current + "\n\n" + para).strip() if current else para
        if len(candidate) <= chunk_size:
            current = candidate
        else:
            if current:
                chunks.append(current)
            # If a single paragraph exceeds chunk_size, split at line boundaries
            if len(para) > chunk_size:
                lines = para.split("\n")
                current = ""
                for ln in lines:
                    trial = (current + "\n" + ln) if current else ln
                    if len(trial) <= chunk_size:
                        current = trial
                    else:
                        if current:
                            chunks.append(current)
                        # Last resort: hard split
                        while len(ln) > chunk_size:
                            chunks.append(ln[:chunk_size])
                            ln = ln[chunk_size:]
                        current = ln
            else:
                current = para
    if current:
        chunks.append(current)
    return chunks


# ── Server-side schedule ticker ───────────────────────────────────────────────
def _start_schedule_ticker():
    """Background thread that checks server-side schedules every 15 seconds."""
    while True:
        time.sleep(15)
        try:
            loop = _main_loop
            if not loop:
                continue
            schedules = _load_schedules()
            now = datetime.now()
            changed = False
            for sched in schedules:
                if not sched.get("enabled", True):
                    continue
                next_run = datetime.fromisoformat(sched["next_run"])
                if next_run.tzinfo is not None:
                    next_run = next_run.astimezone().replace(tzinfo=None)
                if now >= next_run:
                    asyncio.run_coroutine_threadsafe(
                        _run_scheduled_goal(sched), loop,
                    )
                    interval = sched.get("interval_seconds", 0)
                    if interval and interval > 0:
                        sched["next_run"] = (now + timedelta(seconds=interval)).isoformat()
                        changed = True
                    else:
                        sched["enabled"] = False
                        changed = True
            if changed:
                _save_schedules(schedules)
        except Exception as e:
            print(f"[Scheduler] tick error: {e}")


async def _run_scheduled_goal(sched: dict):
    """Launch a scheduled agent run."""
    manager, run_orchestrator = _get_main_refs()
    goal = sched.get("goal", "")
    source = sched.get("source", "schedule")
    s = _load_settings()
    model = (s.get("main_model") or "").strip()
    fast_model = (s.get("fast_model") or "").strip()
    if not model or not goal:
        return
    run_id = str(uuid.uuid4())[:8]
    manager.active_runs[run_id] = True
    session = {
        "id": str(uuid.uuid4()),
        "goal": goal,
        "model": model,
        "created_at": datetime.now().isoformat(),
        "completed": False,
        "steps": [],
        "source": source,
    }
    _save_session(session)
    for ws in list(manager.active_connections):
        try:
            await ws.send_json({"type": "run_start", "run_id": run_id, "goal": f"[Scheduled] {goal}"})
            await ws.send_json({"type": "session_id", "session_id": session["id"], "run_id": run_id})
        except Exception:
            pass

    slack_channel = sched.get("slack_channel", "")
    slack_ws = None
    if slack_channel and _slack_handler:
        try:
            client = _slack_handler.client.web_client
            resp = client.chat_postMessage(channel=slack_channel, text=f"⏰ Scheduled run starting: _{goal}_")
            slack_ws = SlackWebSocket(client, slack_channel, thread_ts=resp.get("ts"))
        except Exception:
            pass

    try:
        fake_ws = slack_ws or (list(manager.active_connections)[0] if manager.active_connections else None)
        if fake_ws:
            await run_orchestrator(
                goal, model, "", False, session, fake_ws,
                fast_model=fast_model, require_approval=False,
                speed_mode=True, use_outlook=s.get("outlook_enabled", False),
                run_id=run_id,
            )
    except Exception as e:
        print(f"[Scheduler] run error: {e}")
        _log_schedule_failure(sched, str(e))
        # One retry after 60s
        await asyncio.sleep(60)
        try:
            fake_ws = slack_ws or (list(manager.active_connections)[0] if manager.active_connections else None)
            if fake_ws:
                await run_orchestrator(
                    goal, model, "", False, session, fake_ws,
                    fast_model=fast_model, require_approval=False,
                    speed_mode=True, use_outlook=s.get("outlook_enabled", False),
                    run_id=run_id,
                )
        except Exception as e2:
            print(f"[Scheduler] retry also failed: {e2}")
            _log_schedule_failure(sched, str(e2), retry=True)
    finally:
        manager.active_runs.pop(run_id, None)


_SCHEDULE_FAILURES_FILE = OUTPUT_DIR / "schedule_failures.jsonl"


def _log_schedule_failure(sched: dict, error: str, retry: bool = False):
    entry = {
        "ts": datetime.now().isoformat(),
        "schedule_id": sched.get("id", ""),
        "goal": sched.get("goal", ""),
        "error": error[:500],
        "retry": retry,
        "acked": False,
    }
    try:
        with open(_SCHEDULE_FAILURES_FILE, "a", encoding="utf-8") as f:
            f.write(json.dumps(entry) + "\n")
    except Exception as ex:
        print(f"[Scheduler] could not log failure: {ex}")


def _get_unacked_failures() -> list[dict]:
    if not _SCHEDULE_FAILURES_FILE.exists():
        return []
    entries = []
    try:
        for line in _SCHEDULE_FAILURES_FILE.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line:
                continue
            entry = json.loads(line)
            if not entry.get("acked"):
                entries.append(entry)
    except Exception:
        pass
    return entries


def _ack_all_failures():
    if not _SCHEDULE_FAILURES_FILE.exists():
        return
    try:
        lines = _SCHEDULE_FAILURES_FILE.read_text(encoding="utf-8").splitlines()
        updated = []
        for line in lines:
            line = line.strip()
            if not line:
                continue
            entry = json.loads(line)
            entry["acked"] = True
            updated.append(json.dumps(entry))
        _SCHEDULE_FAILURES_FILE.write_text("\n".join(updated) + "\n", encoding="utf-8")
    except Exception:
        pass


# ── Slack WebSocket duck-type ─────────────────────────────────────────────────
class SlackWebSocket:
    """Duck-type WebSocket that forwards agent messages to Slack AND all open browser connections."""

    def __init__(self, client, channel: str, thread_ts: str | None = None):
        self._client = client
        self._channel = channel
        self._thread_ts = thread_ts

    async def send_json(self, data: dict):
        manager, _ = _get_main_refs()
        for ws in list(manager.active_connections):
            try:
                await ws.send_json(data)
            except Exception:
                pass

        msg_type = data.get("type", "")
        content  = str(data.get("content") or "")
        if msg_type == "step_start" and data.get("step_index", 0) == 0:
            await self._post("⏳ Bezig…")
        elif msg_type in ("error", "warning") and content:
            await self._post(f"{'❌' if msg_type == 'error' else '⚠️'} {content}")
        elif msg_type in ("final_report", "final"):
            report = content or str(data.get("result") or "")
            if not report:
                await self._post("✅ Run complete — no report generated.")
                return
            formatted = _format_for_slack(report)
            chunks = _smart_channel_chunks(formatted, chunk_size=2800)
            for chunk in chunks:
                await self._post(chunk)
        elif msg_type == "chat_reply" and content:
            await self._post(_format_for_slack(content))

    async def _post(self, text: str):
        try:
            kwargs: dict = {"channel": self._channel, "text": text}
            if self._thread_ts:
                kwargs["thread_ts"] = self._thread_ts
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(
                None, lambda: self._client.chat_postMessage(**kwargs)
            )
        except Exception as e:
            print(f"[Slack] post error: {e}")


# ── Slack command handler ─────────────────────────────────────────────────────
def _handle_slack_command(text: str, channel: str, ts: str, client, say, main_loop: asyncio.AbstractEventLoop):
    manager, run_orchestrator = _get_main_refs()
    lower = text.lower().strip()
    print(f"[Slack] command received: '{lower}' in channel={channel}")

    if lower in ("help", "?", ""):
        say(
            channel=channel, thread_ts=ts,
            text=(
                "🤖 *AgentOrchestrator — Slack Commands*\n\n"
                "Type any message and it runs as an agent goal automatically.\n\n"
                "*Co-Pilot*\n"
                "`copilot: morning brief` — Run the Morning Brief now\n"
                "`copilot: follow-up` — Run the Follow-up Brief now\n"
                "`copilots` — List available Co-Pilots\n\n"
                "*Scheduling*\n"
                "`schedule: <interval> <goal>` — Schedule a recurring run (e.g. `schedule: 2h check inbox`)\n"
                "`schedules` — List all scheduled runs\n"
                "`unschedule: <id>` — Delete a schedule by ID\n\n"
                "*Run Control*\n"
                "`status` — Show active/paused runs\n"
                "`pause` — Pause all running runs\n"
                "`resume` — Resume all paused runs\n"
                "`stop` — Cancel all active runs\n"
                "`help` — Show this message"
            ),
        )
        return

    if lower == "status":
        running = [r for r, v in manager.active_runs.items() if v is True]
        paused  = [r for r, v in manager.active_runs.items() if v == 'paused']
        if running or paused:
            parts = []
            if running:
                parts.append(f"🟢 Running: {', '.join(running)}")
            if paused:
                parts.append(f"⏸ Paused: {', '.join(paused)}")
            say(channel=channel, thread_ts=ts, text="\n".join(parts))
        else:
            say(channel=channel, thread_ts=ts, text="⚪ No active runs.")
        return

    if lower == "pause":
        targets = [r for r, v in manager.active_runs.items() if v is True]
        if not targets:
            say(channel=channel, thread_ts=ts, text="⚪ No running runs to pause.")
            return
        for rid in targets:
            manager.active_runs[rid] = 'paused'
            if _main_loop:
                asyncio.run_coroutine_threadsafe(
                    manager.broadcast({"type": "paused", "run_id": rid, "content": "Paused via Slack."}),
                    _main_loop,
                )
        say(channel=channel, thread_ts=ts, text=f"⏸ Paused {len(targets)} run(s): {', '.join(targets)}")
        return

    if lower == "resume":
        targets = [r for r, v in manager.active_runs.items() if v == 'paused']
        if not targets:
            say(channel=channel, thread_ts=ts, text="⚪ No paused runs to resume.")
            return
        for rid in targets:
            manager.active_runs[rid] = True
            if _main_loop:
                asyncio.run_coroutine_threadsafe(
                    manager.broadcast({"type": "resumed", "run_id": rid, "content": "Resumed via Slack."}),
                    _main_loop,
                )
        say(channel=channel, thread_ts=ts, text=f"▶️ Resumed {len(targets)} run(s): {', '.join(targets)}")
        return

    if lower == "stop":
        for rid in list(manager.active_runs.keys()):
            manager.active_runs[rid] = False
        say(channel=channel, thread_ts=ts, text="🛑 Stop signal sent to all active runs.")
        return

    # ── schedule: <interval> <goal> ──
    if re.match(r"^schedule\b", lower, re.IGNORECASE):
        rest = re.sub(r"^schedule\s*[:\s]\s*", "", text, flags=re.IGNORECASE).strip()
        m = re.match(r"^(\d+)\s*(m|min|mins|minutes?|h|hrs?|hours?|d|days?|w|weeks?|mo|months?)\s+(.+)$", rest, re.IGNORECASE)
        if not m:
            m2 = re.match(r"^(\d+)\s+(.+)$", rest)
            if m2:
                interval_secs = int(m2.group(1)) * 60
                goal = m2.group(2).strip()
            else:
                say(channel=channel, thread_ts=ts,
                    text="⚠️ Usage: `schedule: <interval> <goal>`\nExamples: `schedule: 30m check inbox`, `schedule: 2h summarise reports`, `schedule: 1w weekly review`")
                return
        else:
            amount = int(m.group(1))
            unit = m.group(2).lower()
            goal = m.group(3).strip()
            if unit.startswith("mo") or unit.startswith("month"):
                interval_secs = amount * 2592000
            elif unit.startswith("w") or unit.startswith("week"):
                interval_secs = amount * 604800
            elif unit.startswith("h"):
                interval_secs = amount * 3600
            elif unit.startswith("d"):
                interval_secs = amount * 86400
            else:
                interval_secs = amount * 60
        if interval_secs < 60:
            say(channel=channel, thread_ts=ts, text="⚠️ Minimum interval is 1 minute.")
            return
        sched_id = str(uuid.uuid4())[:8]
        now = datetime.now()
        new_sched = {
            "id": sched_id,
            "goal": goal,
            "interval_seconds": interval_secs,
            "next_run": (now + timedelta(seconds=interval_secs)).isoformat(),
            "created_at": now.isoformat(),
            "enabled": True,
            "source": "slack",
            "slack_channel": channel,
        }
        scheds = _load_schedules()
        scheds.append(new_sched)
        _save_schedules(scheds)
        if interval_secs >= 86400:
            label = f"{interval_secs // 86400}d"
        elif interval_secs >= 3600:
            label = f"{interval_secs // 3600}h"
        else:
            label = f"{interval_secs // 60}m"
        say(channel=channel, thread_ts=ts,
            text=f"⏰ Scheduled `{sched_id}`: _{goal}_ — repeats every {label}\nNext run: {new_sched['next_run']}")
        return

    # ── schedules ──
    if lower == "schedules":
        scheds = _load_schedules()
        active = [s for s in scheds if s.get("enabled", True)]
        if not active:
            say(channel=channel, thread_ts=ts, text="📭 No active schedules.")
            return
        lines = []
        for s in active:
            ivl = s.get("interval_seconds", 0)
            if ivl >= 86400:
                label = f"{ivl // 86400}d"
            elif ivl >= 3600:
                label = f"{ivl // 3600}h"
            else:
                label = f"{ivl // 60}m"
            lines.append(f"• `{s['id']}` — every {label} — _{s['goal']}_\n  Next: {s.get('next_run', '?')}")
        say(channel=channel, thread_ts=ts, text="📋 *Active schedules:*\n" + "\n".join(lines))
        return

    # ── unschedule: <id> ──
    if re.match(r"^(unschedule|deleteschedule|rmschedule)\b", lower, re.IGNORECASE):
        target_id = re.sub(r"^(unschedule|deleteschedule|rmschedule)\s*[:\s]\s*", "", text, flags=re.IGNORECASE).strip()
        if not target_id:
            say(channel=channel, thread_ts=ts, text="⚠️ Usage: `unschedule: <id>`. Use `schedules` to see IDs.")
            return
        scheds = _load_schedules()
        before = len(scheds)
        scheds = [s for s in scheds if s.get("id") != target_id]
        if len(scheds) == before:
            say(channel=channel, thread_ts=ts, text=f"⚠️ No schedule found with ID `{target_id}`.")
            return
        _save_schedules(scheds)
        say(channel=channel, thread_ts=ts, text=f"🗑 Schedule `{target_id}` deleted.")
        return

    s = _load_settings()
    copilot_request = _parse_channel_copilot_command(text, s, "co-pilot start morning brief")
    if copilot_request:
        if copilot_request["action"] != "start":
            say(channel=channel, thread_ts=ts, text=copilot_request["message"])
            return
        goal = copilot_request["goal"]
        start_message = copilot_request["message"]
    else:
        goal = re.sub(r"^(run|start)\s*[:\s]\s*", "", text, flags=re.IGNORECASE).strip()
        start_message = ""

    # ── default: treat any unrecognised text as a run goal ──
    if not goal:
        say(channel=channel, thread_ts=ts,
            text="⚠️ Please provide a goal, or type `help` for commands.")
        return
    model = (s.get("main_model") or "").strip()
    fast_model = (s.get("fast_model") or "").strip()
    if not model:
        say(channel=channel, thread_ts=ts,
            text="⚠️ No model configured. Set a Main Model in Settings first.")
        return
    say(channel=channel, thread_ts=ts,
        text=start_message or f"🚀 Starting run: _{goal}_\nUpdates will follow in this thread.")

    async def _launch_default():
        run_id = str(uuid.uuid4())[:8]
        manager.active_runs[run_id] = True
        session = {
            "id": str(uuid.uuid4()),
            "goal": goal,
            "model": model,
            "created_at": datetime.now().isoformat(),
            "completed": False,
            "steps": [],
            "source": "slack",
        }
        _save_session(session)
        _slack_channel_last_session[channel] = session["id"]
        slack_ws = SlackWebSocket(client, channel, thread_ts=ts)
        for ws in list(manager.active_connections):
            try:
                await ws.send_json({"type": "run_start", "run_id": run_id, "goal": f"[Slack] {goal}"})
                await ws.send_json({"type": "session_id", "session_id": session["id"], "run_id": run_id})
            except Exception:
                pass
        try:
            await run_orchestrator(
                goal, model, "", False, session, slack_ws,
                fast_model=fast_model, require_approval=False,
                speed_mode=True,
                use_outlook=bool(s.get("outlook_enabled", False)),
                run_id=run_id,
            )
        finally:
            manager.active_runs.pop(run_id, None)

    asyncio.run_coroutine_threadsafe(_launch_default(), main_loop)


# ── Slack bot lifecycle ───────────────────────────────────────────────────────
def _start_slack_bot():
    global _slack_handler
    if not SLACK_BOLT_SUPPORT:
        print("[Slack] slack-bolt not installed — bot disabled.")
        return
    s = _load_settings()
    if not s.get("slack_enabled"):
        return
    bot_token = (s.get("slack_bot_token") or "").strip()
    app_token = (s.get("slack_app_token") or "").strip()
    if not bot_token or not app_token:
        print("[Slack] Missing bot or app token — bot not started.")
        return
    main_loop = _main_loop
    if main_loop is None:
        print("[Slack] Main event loop not ready yet, skipping start.")
        return
    slack_app = SlackBoltApp(token=bot_token)

    @slack_app.event("message")
    def on_message(body, say, client):
        event = body.get("event", {})
        if event.get("bot_id") or event.get("subtype"):
            return
        _handle_slack_command(
            (event.get("text") or "").strip(),
            event.get("channel", ""),
            event.get("ts"),
            client, say, main_loop,
        )

    @slack_app.event("app_mention")
    def on_mention(body, say, client):
        event = body.get("event", {})
        text = re.sub(r"<@[A-Z0-9]+>\s*", "", event.get("text", "")).strip()
        _handle_slack_command(
            text, event.get("channel", ""), event.get("ts"),
            client, say, main_loop,
        )

    def _thread():
        global _slack_handler
        try:
            with _slack_handler_lock:
                _slack_handler = SlackSocketHandler(slack_app, app_token)
            print("[Slack] Socket Mode bot started ✅")
            _slack_handler.connect()
            import time
            while _slack_handler is not None:
                time.sleep(5)
        except Exception as e:
            print(f"[Slack] Bot error: {e}")

    threading.Thread(target=_thread, daemon=True, name="slack-bot").start()


def _stop_slack_bot():
    global _slack_handler
    with _slack_handler_lock:
        if _slack_handler:
            try:
                _slack_handler.close()
            except Exception:
                pass
            _slack_handler = None
            print("[Slack] Bot stopped.")


def _restart_slack_bot():
    _stop_slack_bot()
    import time; time.sleep(0.5)
    _start_slack_bot()


# ── Telegram bot ──────────────────────────────────────────────────────────────

class TelegramWebSocket:
    """Duck-type WebSocket that forwards agent messages to Telegram AND all open browser connections."""

    def __init__(self, bot, chat_id: int, reply_to_message_id: int | None = None):
        self._bot = bot
        self._chat_id = chat_id
        self._reply_to = reply_to_message_id

    async def send_json(self, data: dict):
        manager, _ = _get_main_refs()
        for ws in list(manager.active_connections):
            try:
                await ws.send_json(data)
            except Exception:
                pass

        msg_type = data.get("type", "")
        content  = str(data.get("content") or "")
        if msg_type == "step_start" and data.get("step_index", 0) == 0:
            await self._post("⏳ Bezig…")
        elif msg_type in ("error", "warning") and content:
            await self._post(f"{'❌' if msg_type == 'error' else '⚠️'} {content}")
        elif msg_type in ("final_report", "final"):
            report = content or str(data.get("result") or "")
            if not report:
                await self._post("✅ Run complete — no report generated.")
                return
            formatted = _format_for_telegram(report)
            chunks = _smart_channel_chunks(formatted, chunk_size=4000)
            for chunk in chunks:
                await self._post(chunk)
        elif msg_type == "chat_reply" and content:
            await self._post(_format_for_telegram(content))

    async def _post(self, text: str):
        try:
            kwargs: dict = {"chat_id": self._chat_id, "text": text}
            if self._reply_to:
                kwargs["reply_to_message_id"] = self._reply_to
            await self._bot.send_message(**kwargs)
        except Exception as e:
            print(f"[Telegram] post error: {e}")


def _tg_escape(text: str) -> str:
    """Minimal escape for Telegram messages."""
    return text.replace("*", "\\*").replace("_", "\\_") if text else ""


def _handle_telegram_command(text: str, chat_id: int, message_id: int, bot, main_loop: asyncio.AbstractEventLoop):
    """Handle a Telegram message — mirrors Slack _handle_slack_command."""
    manager, run_orchestrator = _get_main_refs()
    lower = text.lower().strip()
    print(f"[Telegram] command received: '{lower}' in chat={chat_id}")

    def _reply(msg: str):
        asyncio.run_coroutine_threadsafe(
            bot.send_message(chat_id=chat_id, text=msg, reply_to_message_id=message_id),
            main_loop,
        )

    if lower in ("help", "/help", "/start", "?", ""):
        _reply(
            "🤖 AgentOrchestrator — Telegram Commands\n\n"
            "Type any message and it runs as an agent goal automatically.\n\n"
            "Co-Pilot\n"
            "/copilot morning brief — Run the Morning Brief now\n"
            "/copilot follow-up — Run the Follow-up Brief now\n"
            "/copilots — List available Co-Pilots\n\n"
            "Scheduling\n"
            "/schedule <interval> <goal> — Schedule a recurring run\n"
            "/schedules — List all scheduled runs\n"
            "/unschedule <id> — Delete a schedule\n\n"
            "Run Control\n"
            "/status — Show active/paused runs\n"
            "/pause — Pause all runs\n"
            "/resume — Resume all runs\n"
            "/stop — Cancel all active runs\n"
            "/help — Show this message"
        )
        return

    if lower == "/status":
        running = [r for r, v in manager.active_runs.items() if v is True]
        paused  = [r for r, v in manager.active_runs.items() if v == 'paused']
        if running or paused:
            parts = []
            if running:
                parts.append(f"🟢 Running: {', '.join(running)}")
            if paused:
                parts.append(f"⏸ Paused: {', '.join(paused)}")
            _reply("\n".join(parts))
        else:
            _reply("⚪ No active runs.")
        return

    if lower == "/pause":
        targets = [r for r, v in manager.active_runs.items() if v is True]
        if not targets:
            _reply("⚪ No running runs to pause.")
            return
        for rid in targets:
            manager.active_runs[rid] = 'paused'
            asyncio.run_coroutine_threadsafe(
                manager.broadcast({"type": "paused", "run_id": rid, "content": "Paused via Telegram."}),
                main_loop,
            )
        _reply(f"⏸ Paused {len(targets)} run(s): {', '.join(targets)}")
        return

    if lower == "/resume":
        targets = [r for r, v in manager.active_runs.items() if v == 'paused']
        if not targets:
            _reply("⚪ No paused runs to resume.")
            return
        for rid in targets:
            manager.active_runs[rid] = True
            asyncio.run_coroutine_threadsafe(
                manager.broadcast({"type": "resumed", "run_id": rid, "content": "Resumed via Telegram."}),
                main_loop,
            )
        _reply(f"▶️ Resumed {len(targets)} run(s): {', '.join(targets)}")
        return

    if lower == "/stop":
        for rid in list(manager.active_runs.keys()):
            manager.active_runs[rid] = False
        _reply("🛑 Stop signal sent to all active runs.")
        return

    # ── /schedule <interval> <goal> ──
    if lower.startswith("/schedule") and not lower.startswith("/schedules"):
        rest = re.sub(r"^/schedule\s*", "", text, flags=re.IGNORECASE).strip()
        m = re.match(r"^(\d+)\s*(m|min|mins|minutes?|h|hrs?|hours?|d|days?|w|weeks?|mo|months?)\s+(.+)$", rest, re.IGNORECASE)
        if not m:
            _reply("⚠️ Usage: /schedule <interval> <goal>\nExamples: /schedule 30m check inbox")
            return
        amount = int(m.group(1))
        unit = m.group(2).lower()
        goal = m.group(3).strip()
        if unit.startswith("mo") or unit.startswith("month"):
            interval_secs = amount * 2592000
        elif unit.startswith("w") or unit.startswith("week"):
            interval_secs = amount * 604800
        elif unit.startswith("h"):
            interval_secs = amount * 3600
        elif unit.startswith("d"):
            interval_secs = amount * 86400
        else:
            interval_secs = amount * 60
        if interval_secs < 60:
            _reply("⚠️ Minimum interval is 1 minute.")
            return
        sched_id = str(uuid.uuid4())[:8]
        now = datetime.now()
        new_sched = {
            "id": sched_id,
            "goal": goal,
            "interval_seconds": interval_secs,
            "next_run": (now + timedelta(seconds=interval_secs)).isoformat(),
            "created_at": now.isoformat(),
            "enabled": True,
            "source": "telegram",
            "telegram_chat_id": chat_id,
        }
        scheds = _load_schedules()
        scheds.append(new_sched)
        _save_schedules(scheds)
        label = f"{interval_secs // 86400}d" if interval_secs >= 86400 else (f"{interval_secs // 3600}h" if interval_secs >= 3600 else f"{interval_secs // 60}m")
        _reply(f"⏰ Scheduled {sched_id}: {goal} — repeats every {label}")
        return

    if lower == "/schedules":
        scheds = _load_schedules()
        active = [s for s in scheds if s.get("enabled", True)]
        if not active:
            _reply("📭 No active schedules.")
            return
        lines = []
        for s in active:
            ivl = s.get("interval_seconds", 0)
            label = f"{ivl // 86400}d" if ivl >= 86400 else (f"{ivl // 3600}h" if ivl >= 3600 else f"{ivl // 60}m")
            lines.append(f"• {s['id']} — every {label} — {s['goal']}")
        _reply("📋 Active schedules:\n" + "\n".join(lines))
        return

    # ── /unschedule <id> ──
    if lower.startswith("/unschedule"):
        target_id = re.sub(r"^/unschedule\s*", "", text, flags=re.IGNORECASE).strip()
        if not target_id:
            _reply("⚠️ Usage: /unschedule <id>")
            return
        scheds = _load_schedules()
        before = len(scheds)
        scheds = [s for s in scheds if s.get("id") != target_id]
        if len(scheds) == before:
            _reply(f"⚠️ No schedule found with ID {target_id}.")
            return
        _save_schedules(scheds)
        _reply(f"🗑 Schedule {target_id} deleted.")
        return

    s = _load_settings()
    copilot_request = _parse_channel_copilot_command(text, s, "/co-pilot start morning brief")
    if copilot_request:
        if copilot_request["action"] != "start":
            _reply(copilot_request["message"])
            return
        goal = copilot_request["goal"]
        start_message = copilot_request["message"]
    else:
        goal = re.sub(r"^/(run|start)\s*", "", text, flags=re.IGNORECASE).strip()
        start_message = ""

    # ── default: treat any text as a run goal ──
    if not goal:
        _reply("⚠️ Please provide a goal, or type /help for commands.")
        return
    model = (s.get("main_model") or "").strip()
    fast_model_val = (s.get("fast_model") or "").strip()
    if not model:
        _reply("⚠️ No model configured. Set a Main Model in Settings first.")
        return
    _reply(start_message or f"🚀 Starting run: {goal}")

    async def _launch_telegram():
        run_id = str(uuid.uuid4())[:8]
        manager.active_runs[run_id] = True
        session = {
            "id": str(uuid.uuid4()),
            "goal": goal,
            "model": model,
            "created_at": datetime.now().isoformat(),
            "completed": False,
            "steps": [],
            "source": "telegram",
        }
        _save_session(session)
        _telegram_chat_last_session[chat_id] = session["id"]
        tg_ws = TelegramWebSocket(bot, chat_id, reply_to_message_id=message_id)
        for ws in list(manager.active_connections):
            try:
                await ws.send_json({"type": "run_start", "run_id": run_id, "goal": f"[Telegram] {goal}"})
                await ws.send_json({"type": "session_id", "session_id": session["id"], "run_id": run_id})
            except Exception:
                pass
        try:
            await run_orchestrator(
                goal, model, "", False, session, tg_ws,
                fast_model=fast_model_val, require_approval=False,
                speed_mode=True,
                use_outlook=bool(s.get("outlook_enabled", False)),
                run_id=run_id,
            )
        finally:
            manager.active_runs.pop(run_id, None)

    asyncio.run_coroutine_threadsafe(_launch_telegram(), main_loop)


def _start_telegram_bot():
    global _telegram_app, _telegram_thread
    if not TELEGRAM_SUPPORT:
        print("[Telegram] python-telegram-bot not installed — bot disabled.")
        return
    s = _load_settings()
    if not s.get("telegram_enabled"):
        return
    bot_token = (s.get("telegram_bot_token") or "").strip()
    if not bot_token:
        print("[Telegram] Missing bot token — bot not started.")
        return
    main_loop = _main_loop
    if main_loop is None:
        print("[Telegram] Main event loop not ready yet, skipping start.")
        return

    def _thread():
        global _telegram_app
        try:
            tg_loop = asyncio.new_event_loop()
            asyncio.set_event_loop(tg_loop)

            app = TelegramAppBuilder().token(bot_token).build()

            async def _on_message(update: "TelegramUpdate", context: "ContextTypes.DEFAULT_TYPE"):
                if not update.message or not update.message.text:
                    return
                _handle_telegram_command(
                    update.message.text.strip(),
                    update.message.chat_id,
                    update.message.message_id,
                    context.bot,
                    main_loop,
                )

            async def _on_start(update: "TelegramUpdate", context: "ContextTypes.DEFAULT_TYPE"):
                if update.message:
                    _handle_telegram_command("/start", update.message.chat_id, update.message.message_id, context.bot, main_loop)

            app.add_handler(TelegramCmdHandler("start", _on_start))
            app.add_handler(TelegramCmdHandler("help", _on_start))
            app.add_handler(TelegramMsgHandler(telegram_filters.TEXT & ~telegram_filters.COMMAND, _on_message))
            # Route commands like /status, /stop, /pause etc. through _on_message
            app.add_handler(TelegramMsgHandler(telegram_filters.COMMAND, _on_message))

            _telegram_app = app
            print("[Telegram] Bot started ✅")
            tg_loop.run_until_complete(app.run_polling(drop_pending_updates=True, close_loop=False))
        except Exception as e:
            print(f"[Telegram] Bot error: {e}")

    _telegram_thread = threading.Thread(target=_thread, daemon=True, name="telegram-bot")
    _telegram_thread.start()


def _stop_telegram_bot():
    global _telegram_app
    if _telegram_app is not None:
        try:
            loop = asyncio.new_event_loop()
            loop.run_until_complete(_telegram_app.stop())
            loop.close()
        except Exception:
            pass
        _telegram_app = None
        print("[Telegram] Bot stopped.")


def _restart_telegram_bot():
    _stop_telegram_bot()
    import time; time.sleep(0.5)
    _start_telegram_bot()


# ── WhatsApp (neonize – pure Python, no Node.js needed) ───────────────────────
import segno as _segno
import shutil as _shutil

_wa_client = None          # neonize NewClient instance
_wa_lock = threading.Lock()
_wa_status: str = "disconnected"
_wa_qr_data_url: str | None = None
_wa_phone: str | None = None
_wa_self_chat_jid: str | None = None  # special "Message Yourself" chat JID
_wa_connected_at: str | None = None
_wa_last_error: str | None = None
_wa_thread: threading.Thread | None = None
_wa_debug_events: list[dict] = []  # circular buffer of last 50 message events
_WA_DEFAULT_TEST_MESSAGE = "Testbericht van AgentOrchestrator: de WhatsApp-verbinding werkt."


def _wa_session_path() -> str:
    from backend.config import WORKSPACE_ROOT
    d = WORKSPACE_ROOT / ".whatsapp"
    d.mkdir(exist_ok=True)
    return str(d / "session")


def _wa_qr_handler(_client, data_qr: bytes):
    """Convert QR data to a PNG data-URL for the web UI."""
    global _wa_qr_data_url, _wa_status, _wa_last_error
    try:
        qr = _segno.make_qr(data_qr)
        buf = io.BytesIO()
        qr.save(buf, kind="png", scale=8, border=2)
        b64 = base64.b64encode(buf.getvalue()).decode()
        _wa_qr_data_url = f"data:image/png;base64,{b64}"
        _wa_status = "qr_ready"
        _wa_last_error = None
        print("[WhatsApp] QR code ready for scanning")
    except Exception as e:
        print(f"[WhatsApp] QR generation error: {e}")
        _wa_last_error = str(e)


def _wa_on_connected(_client, _ev):
    global _wa_status, _wa_qr_data_url, _wa_phone, _wa_connected_at, _wa_last_error, _wa_self_chat_jid
    _wa_status = "connected"
    _wa_qr_data_url = None
    _wa_last_error = None
    _wa_connected_at = utc_now_iso_z()
    try:
        me = _client.get_me()
        _wa_phone = me.JID.User if me and me.JID else None
    except Exception:
        _wa_phone = None
    # Resolve the LID (Linked Identity) for self-chat detection
    if _wa_phone and _client:
        try:
            from neonize.utils.jid import build_jid
            pn_jid = build_jid(_wa_phone)
            lid_jid = _client.get_lid_from_pn(pn_jid)
            _wa_self_chat_jid = lid_jid.User if lid_jid else None
            print(f"[WhatsApp] Self-chat LID: {_wa_self_chat_jid}")
        except Exception as e:
            print(f"[WhatsApp] Could not resolve LID: {e}")
    print(f"[WhatsApp] Connected! Phone: {_wa_phone}, SelfChatJID: {_wa_self_chat_jid}")


def _wa_on_disconnected(_client, _ev):
    global _wa_status
    if _wa_status not in ("disconnected",):
        _wa_status = "reconnecting"
    print("[WhatsApp] Disconnected — will attempt reconnect")


def _wa_on_logged_out(_client, _ev):
    global _wa_status, _wa_phone, _wa_connected_at, _wa_qr_data_url, _wa_self_chat_jid
    _wa_status = "disconnected"
    _wa_phone = None
    _wa_self_chat_jid = None
    _wa_connected_at = None
    _wa_qr_data_url = None
    print("[WhatsApp] Logged out")


def _wa_extract_text(msg) -> str:
    """Extract plain text from a WhatsApp Message proto, handling wrappers."""
    msg = _wa_unwrap_message(msg)
    # Direct conversation field
    text = msg.conversation or ""
    if text:
        return text
    # Extended text (links / formatting)
    if msg.HasField("extendedTextMessage"):
        text = msg.extendedTextMessage.text or ""
        if text:
            return text
    for media_field in ("imageMessage", "documentMessage", "videoMessage"):
        if msg.HasField(media_field):
            text = str(getattr(msg, media_field).caption or "").strip()
            if text:
                return text
    return ""


def _wa_unwrap_message(msg):
    """Return the innermost WhatsApp message payload after common wrappers."""
    current = msg
    while current is not None:
        inner = None
        if current.HasField("editedMessage") and current.editedMessage.HasField("message"):
            inner = current.editedMessage.message
        elif current.HasField("ephemeralMessage") and current.ephemeralMessage.HasField("message"):
            inner = current.ephemeralMessage.message
        elif current.HasField("viewOnceMessage") and current.viewOnceMessage.HasField("message"):
            inner = current.viewOnceMessage.message
        elif current.HasField("viewOnceMessageV2") and current.viewOnceMessageV2.HasField("message"):
            inner = current.viewOnceMessageV2.message
        elif current.HasField("documentWithCaptionMessage") and current.documentWithCaptionMessage.HasField("message"):
            inner = current.documentWithCaptionMessage.message
        if inner is None:
            return current
        current = inner
    return msg


def _wa_media_storage_dir() -> Path:
    path = WORKSPACE_ROOT / _WA_MEDIA_DIR_REL
    path.mkdir(parents=True, exist_ok=True)
    return path


def _wa_guess_media_suffix(kind: str, mime: str, filename: str = "") -> str:
    name_suffix = Path(filename or "").suffix.strip()
    if name_suffix:
        return name_suffix if name_suffix.startswith(".") else f".{name_suffix}"
    guessed = mimetypes.guess_extension((mime or "").split(";", 1)[0].strip()) or ""
    if guessed:
        return guessed
    return {
        "image": ".jpg",
        "document": ".bin",
        "video": ".mp4",
        "audio": ".ogg",
    }.get(kind, ".bin")


def _wa_extract_media_info(msg) -> dict | None:
    msg = _wa_unwrap_message(msg)
    candidates = (
        ("image", "imageMessage"),
        ("document", "documentMessage"),
        ("video", "videoMessage"),
        ("audio", "audioMessage"),
    )
    for kind, field_name in candidates:
        if not msg.HasField(field_name):
            continue
        media_msg = getattr(msg, field_name)
        mime = str(getattr(media_msg, "mimetype", "") or "").strip()
        caption = str(getattr(media_msg, "caption", "") or "").strip()
        original_name = str(getattr(media_msg, "fileName", "") or "").strip()
        suffix = _wa_guess_media_suffix(kind, mime, original_name)
        return {
            "kind": kind,
            "mime": mime,
            "caption": caption,
            "original_name": original_name,
            "suffix": suffix,
            "message": msg,
        }
    return None


def _wa_save_incoming_media(client, msg) -> dict | None:
    media = _wa_extract_media_info(msg)
    if not media:
        return None
    suffix = media.get("suffix") or ".bin"
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    dest_dir = _wa_media_storage_dir()
    dest = dest_dir / f"wa-{stamp}-{uuid.uuid4().hex[:8]}{suffix}"
    client.download_any(media["message"], path=str(dest))
    rel_path = dest.relative_to(WORKSPACE_ROOT).as_posix()
    saved = dict(media)
    saved.update({
        "path": str(dest),
        "rel_path": rel_path,
        "name": dest.name,
    })
    return saved


def _wa_build_incoming_goal(text: str, media: dict | None = None) -> str:
    prompt = str(text or "").strip()
    if not media:
        return prompt
    rel_path = str(media.get("rel_path") or media.get("path") or "").strip()
    mime = str(media.get("mime") or "").strip()
    kind = str(media.get("kind") or "bestand").strip()
    if mime.startswith("image/"):
        if prompt:
            return f"{prompt}\n\nGebruik hiervoor de afbeelding {rel_path}."
        return f"Beschrijf exact wat zichtbaar is op deze afbeelding: {rel_path}."
    if prompt:
        return f"{prompt}\n\nGebruik hiervoor het meegestuurde {kind} op {rel_path}."
    return f"Analyseer het meegestuurde {kind}: {rel_path}."


def _wa_is_direct_image_query(media: dict | None) -> bool:
    if not media:
        return False
    mime = str(media.get("mime") or "").strip().lower()
    kind = str(media.get("kind") or "").strip().lower()
    return kind == "image" or mime.startswith("image/")


def _wa_clean_image_reply(result: str) -> str:
    raw = str(result or "").strip()
    if not raw:
        return raw
    if raw.startswith("[Image:"):
        parts = raw.splitlines()
        body = "\n".join(parts[1:]).strip()
        if body:
            return body
    return raw


def _wa_store_completed_reply(session_id: str, goal: str, reply: str) -> None:
    session = _load_session(session_id) if session_id else None
    if not session:
        session = {
            "id": session_id or str(uuid.uuid4()),
            "goal": goal,
            "created_at": datetime.now().isoformat(),
            "completed": False,
            "steps": [],
            "source": "whatsapp",
        }
    session["goal"] = goal
    session["updated_at"] = datetime.now().isoformat()
    session["completed"] = True
    session["final_report"] = reply
    session["steps"] = [{"step": goal, "result": reply, "verdict": "DONE"}]
    _save_session(session)


def _handle_whatsapp_image_message(text: str, sender: str, media: dict):
    """Answer an incoming WhatsApp image directly via the vision tool."""
    main_loop = _main_loop
    if not main_loop:
        print("[WhatsApp] ERROR: main_loop is None — cannot launch direct image reply")
        return

    prompt = str(text or "").strip() or "Beschrijf exact wat zichtbaar is op deze afbeelding."
    image_source = str(media.get("rel_path") or media.get("path") or "").strip()
    goal = _wa_build_incoming_goal(prompt, media)
    session_id = _whatsapp_chat_last_session.get(sender)
    if not session_id:
        session_id = str(uuid.uuid4())
        _whatsapp_chat_last_session[sender] = session_id
    run_id = str(uuid.uuid4())[:8]

    async def _launch_whatsapp_image():
        ws = WhatsAppWebSocket(sender)
        manager, _ = _get_main_refs()
        manager.active_runs[run_id] = True
        for bws in list(manager.active_connections):
            try:
                await bws.send_json({"type": "run_start", "run_id": run_id, "goal": f"[WhatsApp] {goal}"})
                await bws.send_json({"type": "session_id", "session_id": session_id, "run_id": run_id})
            except Exception:
                pass
        try:
            await ws.send_json({"type": "status", "content": "Afbeelding analyseren...", "run_id": run_id})
            loop = asyncio.get_event_loop()
            from backend.tool_general_runtime import _describe_image

            raw_reply = await loop.run_in_executor(None, _describe_image, image_source, prompt, "")
            reply = _wa_clean_image_reply(raw_reply)
            _wa_store_completed_reply(session_id, goal, reply)
            await ws.send_json({
                "type": "chat_reply",
                "content": reply,
                "session_id": session_id,
                "run_id": run_id,
                "goal": goal,
            })
        except Exception as exc:
            await ws.send_json({"type": "error", "content": f"WhatsApp image analysis error: {exc}", "run_id": run_id})
        finally:
            manager.active_runs.pop(run_id, None)
            for bws in list(manager.active_connections):
                try:
                    await bws.send_json({"type": "run_done", "run_id": run_id})
                except Exception:
                    pass

    asyncio.run_coroutine_threadsafe(_launch_whatsapp_image(), main_loop)


def _wa_on_message(_client, ev):
    """Handle incoming WhatsApp message → forward to agent.

    Only messages in the user's **self-chat** (Message Yourself) are
    processed.  Messages in any other chat — even those sent by the
    owner — are silently ignored.
    """
    try:
        source = ev.Info.MessageSource
        chat_jid = source.Chat
        chat_user = chat_jid.User if chat_jid else None
        chat_server = chat_jid.Server if chat_jid else None
        sender_user = source.Sender.User if source.Sender else None
        owner = _wa_phone or (sender_user if source.IsFromMe else None)
        is_from_me = source.IsFromMe
        is_group = source.IsGroup
        is_edit = getattr(ev, "IsEdit", False)

        msg = ev.Message
        text = _wa_extract_text(msg)
        media = None
        media_error = ""
        try:
            media = _wa_save_incoming_media(_client, msg)
        except Exception as exc:
            media_error = str(exc)

        # Self-chat detection: WhatsApp may use a special chat JID for
        # "Message Yourself" that differs from the user's phone number.
        # We detect it by checking IsFromMe + matching phone or stored
        # self-chat JID.
        is_self_chat = (chat_user == owner) if owner else False
        if not is_self_chat and is_from_me and not is_group and owner:
            # Check if this chat JID matches our stored self-chat JID
            global _wa_self_chat_jid
            if _wa_self_chat_jid and chat_user == _wa_self_chat_jid:
                is_self_chat = True

        # Build debug record
        debug = {
            "ts": datetime.now().isoformat(),
            "IsFromMe": is_from_me,
            "Chat": chat_user,
            "ChatServer": chat_server,
            "Sender": sender_user,
            "Owner": owner,
            "IsGroup": is_group,
            "IsEdit": is_edit,
            "IsEphemeral": getattr(ev, "IsEphemeral", False),
            "isSelfChat": is_self_chat,
            "text": text[:200] if text else "",
            "hasMedia": bool(media),
            "mediaPath": str((media or {}).get("rel_path") or ""),
            "mediaKind": str((media or {}).get("kind") or ""),
            "mediaError": media_error,
            "filtered": None,
        }

        _wa_log = f"[WhatsApp] MSG received — IsFromMe={is_from_me}, Chat={chat_user}@{chat_server}, Sender={sender_user}, Owner={owner}, IsGroup={is_group}, IsEdit={is_edit}, isSelfChat={is_self_chat}, text={text[:80]!r}"
        try:
            print(_wa_log)
        except UnicodeEncodeError:
            print(_wa_log.encode("ascii", errors="replace").decode("ascii"))

        if not is_from_me:
            debug["filtered"] = "not_from_me"
            _wa_debug_events.append(debug)
            if len(_wa_debug_events) > 50:
                _wa_debug_events.pop(0)
            return
        if not is_self_chat:
            debug["filtered"] = f"chat_mismatch({chat_user}!={owner})"
            _wa_debug_events.append(debug)
            if len(_wa_debug_events) > 50:
                _wa_debug_events.pop(0)
            return

        text = text.strip()
        if not text and not media:
            debug["filtered"] = "empty_text"
            _wa_debug_events.append(debug)
            if len(_wa_debug_events) > 50:
                _wa_debug_events.pop(0)
            return
        if media_error and not media:
            debug["filtered"] = f"media_error({media_error})"
            _wa_debug_events.append(debug)
            if len(_wa_debug_events) > 50:
                _wa_debug_events.pop(0)
            try:
                _wa_send_tool(owner or "", f"Kon media niet opslaan: {media_error}")
            except Exception:
                pass
            return

        debug["filtered"] = "PASSED"
        _wa_debug_events.append(debug)
        if len(_wa_debug_events) > 50:
            _wa_debug_events.pop(0)

        # Respond back to the owner's own number
        owner = _wa_phone or source.Sender.User
        if media and _wa_is_direct_image_query(media):
            threading.Thread(
                target=_handle_whatsapp_image_message, args=(text, owner, media), daemon=True
            ).start()
        else:
            goal = _wa_build_incoming_goal(text, media)
            threading.Thread(
                target=_handle_whatsapp_message, args=(goal, owner), daemon=True
            ).start()
    except Exception as e:
        print(f"[WhatsApp] message handling error: {e}")
        import traceback; traceback.print_exc()
        _wa_debug_events.append({"ts": datetime.now().isoformat(), "error": str(e)})
        if len(_wa_debug_events) > 50:
            _wa_debug_events.pop(0)


def _wa_create_client():
    """Create and configure a neonize NewClient with event handlers."""
    from neonize.client import NewClient
    from neonize.events import ConnectedEv, DisconnectedEv, LoggedOutEv, MessageEv

    client = NewClient(_wa_session_path())
    client.qr(_wa_qr_handler)

    @client.event(ConnectedEv)
    def _on_connected(c, ev):
        _wa_on_connected(c, ev)

    @client.event(DisconnectedEv)
    def _on_disconnected(c, ev):
        _wa_on_disconnected(c, ev)

    @client.event(LoggedOutEv)
    def _on_logged_out(c, ev):
        _wa_on_logged_out(c, ev)

    @client.event(MessageEv)
    def _on_message(c, ev):
        _wa_on_message(c, ev)

    return client


def _wa_get_status_dict() -> dict:
    return {
        "ok": True,
        "status": _wa_status,
        "qrDataUrl": _wa_qr_data_url if _wa_status == "qr_ready" else None,
        "phoneNumber": _wa_phone,
        "connectedAt": _wa_connected_at,
        "lastError": _wa_last_error,
    }


def _wa_start() -> "tuple[bool, dict]":
    """Start WhatsApp neonize client. Returns (ok, status_dict)."""
    global _wa_client, _wa_status, _wa_thread, _wa_last_error
    with _wa_lock:
        if _wa_client is not None and _wa_status in ("connected", "qr_ready", "connecting"):
            return True, _wa_get_status_dict()
        _wa_status = "connecting"
        _wa_last_error = None
        try:
            _wa_client = _wa_create_client()
        except Exception as e:
            _wa_status = "disconnected"
            _wa_last_error = str(e)
            return False, _wa_get_status_dict()

        def _run():
            global _wa_status, _wa_last_error
            try:
                _wa_client.connect()
            except Exception as e:
                _wa_status = "disconnected"
                _wa_last_error = str(e)
                print(f"[WhatsApp] connect error: {e}")
            finally:
                if _wa_status in ("connecting", "reconnecting"):
                    _wa_status = "disconnected"
                    if not _wa_last_error:
                        _wa_last_error = (
                            "WhatsApp client stopped before a QR code or connection was established. "
                            "Check dependency versions and launcher logs."
                        )

        _wa_thread = threading.Thread(target=_run, daemon=True)
        _wa_thread.start()

    import time
    for _ in range(30):
        time.sleep(0.1)
        if _wa_status in ("qr_ready", "connected"):
            return True, _wa_get_status_dict()
        if _wa_thread is not None and not _wa_thread.is_alive() and _wa_status == "disconnected":
            return False, _wa_get_status_dict()
    return True, _wa_get_status_dict()


def _wa_stop():
    """Stop and clean up WhatsApp client."""
    global _wa_client, _wa_status, _wa_phone, _wa_connected_at, _wa_qr_data_url, _wa_last_error, _wa_self_chat_jid
    with _wa_lock:
        if _wa_client is not None:
            try:
                _wa_client.disconnect()
            except Exception:
                pass
            _wa_client = None
        _wa_status = "disconnected"
        _wa_phone = None
        _wa_self_chat_jid = None
        _wa_connected_at = None
        _wa_qr_data_url = None
        _wa_last_error = None


def _wa_wipe_session():
    """Delete session files so next connect requires a fresh QR scan."""
    from backend.config import WORKSPACE_ROOT
    session_dir = WORKSPACE_ROOT / ".whatsapp"
    if session_dir.exists():
        _shutil.rmtree(session_dir, ignore_errors=True)


def _wa_auto_start():
    """Called at server startup — start if WhatsApp is enabled."""
    s = _load_settings()
    if s.get("whatsapp_enabled"):
        ok, info = _wa_start()
        if ok:
            print("[WhatsApp] Auto-started on startup.")
        else:
            print(f"[WhatsApp] Auto-start failed: {info.get('lastError', '?')}")


def _wa_is_connected() -> bool:
    """Return True when WhatsApp is connected and can send messages."""
    return _wa_client is not None and _wa_status == "connected"


def _wa_send_tool(recipient: str, text: str) -> str:
    """Send a WhatsApp message. Called by _execute_tool in tools_impl."""
    if _wa_client is None or _wa_status != "connected":
        return "WhatsApp is niet verbonden. Vraag de gebruiker om WhatsApp te koppelen via Instellingen."
    recipient = recipient.strip()
    text = text.strip()
    if not recipient or not text:
        return "Missing recipient or text."
    try:
        from neonize.utils.jid import build_jid
        jid = build_jid(recipient)
        _wa_client.send_message(jid, text[:4096])
        return f"WhatsApp message sent to {recipient}."
    except Exception as e:
        return f"WhatsApp send error: {e}"


def _wa_send_connected_test_message(text: str) -> tuple[bool, str]:
    """Send a test message to the currently linked WhatsApp account."""
    if _wa_client is None or _wa_status != "connected":
        return False, "WhatsApp is not connected"
    recipient = (_wa_phone or "").strip()
    if not recipient:
        return False, "No linked WhatsApp number available"
    message = text.strip()
    if not message:
        return False, "Missing text"
    result = _wa_send_tool(recipient, message)
    if result.startswith("WhatsApp message sent to"):
        return True, result
    return False, result


class WhatsAppWebSocket:
    """Duck-type WebSocket that forwards agent messages to WhatsApp AND all open browser connections."""

    def __init__(self, recipient: str):
        self._recipient = recipient

    async def send_json(self, data: dict):
        manager, _ = _get_main_refs()
        for ws in list(manager.active_connections):
            try:
                await ws.send_json(data)
            except Exception:
                pass

        msg_type = data.get("type", "")
        content  = str(data.get("content") or "")
        if msg_type == "step_start" and data.get("step_index", 0) == 0:
            self._post_sync("⏳ Bezig…")
        elif msg_type in ("error", "warning") and content:
            self._post_sync(f"{'❌' if msg_type == 'error' else '⚠️'} {content}")
        elif msg_type in ("final_report", "final"):
            report = content or str(data.get("result") or "")
            if not report:
                self._post_sync("✅ Run complete — no report generated.")
                return
            formatted = _format_for_whatsapp(report)
            chunks = _smart_channel_chunks(formatted, chunk_size=4000)
            for chunk in chunks:
                self._post_sync(chunk)
        elif msg_type == "chat_reply" and content:
            self._post_sync(_format_for_whatsapp(content))

    def _post_sync(self, text: str):
        """Send message via neonize client."""
        if _wa_client is None or _wa_status != "connected":
            print("[WhatsApp] Cannot send — not connected")
            return
        try:
            from neonize.utils.jid import build_jid
            jid = build_jid(self._recipient)
            _wa_client.send_message(jid, text[:4096])
        except Exception as e:
            print(f"[WhatsApp] send error: {e}")


def _handle_whatsapp_message(text: str, sender: str):
    """Handle an incoming WhatsApp message."""
    manager, run_orchestrator = _get_main_refs()
    main_loop = _main_loop
    lower = text.lower().strip()
    print(f"[WhatsApp] message from {sender}: '{lower}'")

    def _reply(msg: str):
        ws = WhatsAppWebSocket(sender)
        ws._post_sync(msg)

    if lower in ("/help", "/start", "help"):
        _reply(
            "🤖 AgentOrchestrator — WhatsApp Commands\n\n"
            "Send any message to run it as an agent goal.\n\n"
            "Co-Pilot: copilot: morning brief | copilot: follow-up | copilots\n"
            "Scheduling: schedule: <interval> <goal> | schedules | unschedule: <id>\n"
            "Run Control: status | pause | resume | stop\n"
            "help — Show this message"
        )
        return

    if lower == "status":
        active = [r for r in manager.active_runs.values() if r.get("status") == "running"]
        if active:
            lines = [f"🏃 {len(active)} active run(s):"]
            for r in active:
                lines.append(f"· {r.get('goal', '?')}")
            _reply("\n".join(lines))
        else:
            _reply("💤 No active runs.")
        return

    if lower in ("pause", "resume"):
        manager.paused = (lower == "pause")
        _reply(f"{'⏸️ Paused' if manager.paused else '▶️ Resumed'}")
        return

    if lower == "stop":
        stopped = 0
        for rid, run in list(manager.active_runs.items()):
            if isinstance(run, dict) and run.get("status") == "running":
                run["kill"] = True
                stopped += 1
            elif run:  # bool True → mark stopped
                manager.active_runs[rid] = False
                stopped += 1
        _reply(f"🛑 Stop requested for {stopped} run(s)." if stopped else "No active runs to stop.")
        return

    if lower.startswith("schedule "):
        parts = text[9:].strip().split(" ", 1)
        if len(parts) < 2:
            _reply("Usage: schedule <cron> <goal>")
            return
        cron_expr, goal = parts
        schedules = _load_schedules()
        new_id = str(uuid.uuid4())[:8]
        schedules.append({"id": new_id, "cron": cron_expr, "goal": goal, "enabled": True})
        _save_schedules(schedules)
        _reply(f"📅 Scheduled [{new_id}]: {cron_expr} — {goal}")
        return

    if lower == "schedules":
        schedules = _load_schedules()
        if not schedules:
            _reply("No schedules configured.")
            return
        lines = ["📋 Schedules:"]
        for s in schedules:
            status = "✅" if s.get("enabled", True) else "⏸️"
            lines.append(f"{status} [{s['id']}] {s.get('cron', '?')} — {s.get('goal', '?')}")
        _reply("\n".join(lines))
        return

    if lower.startswith("unschedule "):
        sid = text[11:].strip()
        schedules = _load_schedules()
        new_list = [s for s in schedules if s.get("id") != sid]
        if len(new_list) == len(schedules):
            _reply(f"Schedule '{sid}' not found.")
        else:
            _save_schedules(new_list)
            _reply(f"🗑️ Removed schedule {sid}.")
        return

    s = _load_settings()
    copilot_request = _parse_channel_copilot_command(text, s, "co-pilot start morning brief")
    if copilot_request:
        if copilot_request["action"] != "start":
            _reply(copilot_request["message"])
            return
        goal = copilot_request["goal"]
        start_message = copilot_request["message"]
    else:
        goal = text.strip()
        start_message = ""

    # Default — treat as run goal
    if not goal:
        return

    session_id = _whatsapp_chat_last_session.get(sender)
    prev_session = _load_session(session_id) if session_id else None
    # Expire stale sessions — start fresh if >10 min since last activity
    if prev_session:
        _last_ts = prev_session.get("updated_at") or prev_session.get("created_at") or ""
        try:
            _age = (datetime.now() - datetime.fromisoformat(_last_ts)).total_seconds()
        except Exception:
            _age = 9999
        if _age > 600:
            prev_session = None
    if not prev_session:
        session_id = str(uuid.uuid4())
        _whatsapp_chat_last_session[sender] = session_id

    ws = WhatsAppWebSocket(sender)
    _reply(start_message or f"🚀 Starting: {goal}")

    run_id = str(uuid.uuid4())[:8]
    model = (s.get("main_model") or "").strip()
    fast_model = (s.get("fast_model") or "").strip()

    if prev_session:
        # Continue existing session — preserve chat_history and task_state
        session = prev_session
        session["goal"] = goal
        session["model"] = model
        session["completed"] = False
    else:
        session = {
            "id": session_id,
            "goal": goal,
            "model": model,
            "created_at": datetime.now().isoformat(),
            "completed": False,
            "steps": [],
            "source": "whatsapp",
        }
    _save_session(session)

    manager.active_runs[run_id] = True

    # Notify open browser connections
    for bws in list(manager.active_connections):
        try:
            asyncio.run_coroutine_threadsafe(
                bws.send_json({"type": "run_start", "run_id": run_id, "goal": f"[WhatsApp] {goal}"}),
                main_loop,
            )
            asyncio.run_coroutine_threadsafe(
                bws.send_json({"type": "session_id", "session_id": session_id, "run_id": run_id}),
                main_loop,
            )
        except Exception:
            pass

    async def _launch_whatsapp():
        try:
            print(f"[WhatsApp] Orchestrator starting for: {goal}")
            await run_orchestrator(
                goal, model, "", False, session, ws,
                fast_model=fast_model, require_approval=False,
                speed_mode=True,
                use_outlook=bool(s.get("outlook_enabled", False)),
                run_id=run_id,
            )
            print(f"[WhatsApp] Orchestrator finished for: {goal}")
        except Exception as exc:
            print(f"[WhatsApp] Orchestrator error: {exc}")
        finally:
            manager.active_runs.pop(run_id, None)
            # Notify browser so history / running-flows update immediately
            for bws in list(manager.active_connections):
                try:
                    await bws.send_json({"type": "run_done", "run_id": run_id})
                except Exception:
                    pass

    if main_loop:
        print(f"[WhatsApp] Launching orchestrator for goal: {goal}")
        asyncio.run_coroutine_threadsafe(_launch_whatsapp(), main_loop)
    else:
        print(f"[WhatsApp] ERROR: main_loop is None — cannot launch orchestrator")


# ── WhatsApp API endpoints ───────────────────────────────────────────────────

@router.get("/api/integrations/whatsapp/status")
async def whatsapp_status():
    """Get current WhatsApp connection status."""
    return _wa_get_status_dict()


@router.post("/api/integrations/whatsapp/start")
async def whatsapp_start():
    """Start WhatsApp client and begin QR code generation."""
    ok, info = _wa_start()
    return info


@router.get("/api/integrations/whatsapp/qr")
async def whatsapp_qr():
    """Get current QR code data URL."""
    return {"ok": True, "qrDataUrl": _wa_qr_data_url}


@router.post("/api/integrations/whatsapp/relink")
async def whatsapp_relink():
    """Wipe session and generate a fresh QR code."""
    _wa_stop()
    _wa_wipe_session()
    ok, info = _wa_start()
    return info


@router.delete("/api/integrations/whatsapp/disconnect")
async def whatsapp_disconnect():
    """Disconnect WhatsApp and wipe session data."""
    if _wa_client is not None:
        try:
            _wa_client.logout()
        except Exception:
            pass
    _wa_stop()
    _wa_wipe_session()
    return {"ok": True, "status": "disconnected"}


@router.post("/api/integrations/whatsapp/send")
async def whatsapp_send(request: Request):
    """Send a test message via WhatsApp."""
    body = {}
    try:
        body = await request.json()
    except Exception:
        body = {}
    text = (body.get("text") or _WA_DEFAULT_TEST_MESSAGE).strip()
    ok, result = _wa_send_connected_test_message(text)
    if ok:
        return {"ok": True, "message": "Test message sent to linked WhatsApp."}
    return {"ok": False, "error": result}

@router.get("/api/integrations/whatsapp/debug-events")
async def whatsapp_debug_events():
    """Return recent WhatsApp message events for debugging."""
    return {"ok": True, "events": list(_wa_debug_events), "phone": _wa_phone, "selfChatJid": _wa_self_chat_jid, "status": _wa_status}

@router.get("/api/integrations/outlook/test")
def api_outlook_test():
    if not OUTLOOK_SUPPORT:
        return {"ok": False, "error": "pywin32 not installed. Run: pip install pywin32"}
    try:
        from backend.mail_helpers_runtime import _outlook_select_mailboxes
        with _outlook_session() as (_, ns):
            inboxes = _outlook_select_mailboxes(ns, folder_id=6, mailbox="ALL")
            if not inboxes:
                return {"ok": False, "error": "Connected to Outlook but could not resolve any Inbox folders."}
            by_mailbox: dict[str, int] = {}
            total = 0
            for store_name, inbox in inboxes:
                try:
                    c = int(inbox.Items.Count)
                except Exception:
                    c = 0
                by_mailbox[store_name] = c
                total += c
        return {
            "ok": True,
            "inbox_count": total,
            "by_mailbox": by_mailbox,
            "message": f"Connected. {total} total inbox items across {len(by_mailbox)} mailbox(es).",
        }
    except Exception as e:
        return {"ok": False, "error": str(e)}


@router.get("/api/integrations/outlook/folders")
def api_outlook_folders():
    """Return the full Outlook folder tree for all stores."""
    if not OUTLOOK_SUPPORT:
        return {"ok": False, "error": "pywin32 not installed."}
    try:
        with _outlook_session() as (_, ns):
            stores = getattr(ns, "Stores", None)
            if stores is None:
                return {"ok": False, "error": "Could not access Outlook stores."}
            result: list[dict] = []
            for i in range(1, int(stores.Count) + 1):
                try:
                    store = stores.Item(i)
                    display = str(getattr(store, "DisplayName", "") or f"Store {i}")
                    root = store.GetRootFolder()
                    children = _walk_outlook_folders(root, depth=0, max_depth=6)
                    result.append({"store": display, "folders": children})
                except Exception:
                    pass
        return {"ok": True, "stores": result}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def _walk_outlook_folders(parent_folder, depth: int = 0, max_depth: int = 6) -> list[dict]:
    """Recursively walk Outlook COM folder tree."""
    items: list[dict] = []
    if depth > max_depth:
        return items
    try:
        subfolders = parent_folder.Folders
        for j in range(1, int(subfolders.Count) + 1):
            try:
                sub = subfolders.Item(j)
                name = str(getattr(sub, "Name", "") or "")
                path = str(getattr(sub, "FolderPath", "") or "").replace("\\\\", "/").replace("\\", "/").lstrip("/")
                try:
                    count = int(sub.Items.Count)
                except Exception:
                    count = 0
                children = _walk_outlook_folders(sub, depth + 1, max_depth)
                items.append({
                    "name": name,
                    "path": path,
                    "count": count,
                    "children": children,
                })
            except Exception:
                pass
    except Exception:
        pass
    return items


# ── Google Gmail label tree ────────────────────────────────────────────────────


def _build_gmail_label_tree(labels: list[dict]) -> list[dict]:
    """Convert flat Gmail labels list into a nested tree structure."""
    nodes: dict[str, dict] = {}
    # Build a dict keyed by label id
    for lbl in labels:
        lid = lbl.get("id", "")
        name = lbl.get("name", "")
        ltype = lbl.get("type", "user")
        msg_total = lbl.get("messagesTotal", 0) or 0
        # Skip hidden system labels
        if ltype == "system" and lid in ("CHAT", "SPAM", "TRASH", "UNREAD", "STARRED", "IMPORTANT", "CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"):
            continue
        nodes[lid] = {
            "id": lid,
            "name": name,
            "path": name,
            "count": msg_total,
            "children": [],
            "type": ltype,
        }

    # Gmail nested labels use "/" in the name (e.g. "Work/Projects/Active")
    roots: list[dict] = []
    for node in nodes.values():
        parts = node["name"].split("/")
        if len(parts) > 1:
            # Find parent by joining all but last part
            parent_name = "/".join(parts[:-1])
            parent = next((n for n in nodes.values() if n["name"] == parent_name), None)
            if parent:
                node["name"] = parts[-1]  # Show short name
                parent["children"].append(node)
                continue
        roots.append(node)

    # Sort: system labels first (INBOX, SENT, DRAFT, etc.), then user labels
    def _sort_key(n: dict) -> tuple:
        prio = {"INBOX": 0, "SENT": 1, "DRAFT": 2, "DRAFTS": 2}.get(n.get("id", ""), 10)
        return (prio, n["name"].lower())
    roots.sort(key=_sort_key)
    for node in nodes.values():
        node["children"].sort(key=_sort_key)

    # Remove internal keys before returning
    def _clean(n: dict) -> dict:
        return {
            "name": n["name"],
            "path": n["path"],
            "count": n["count"],
            "children": [_clean(c) for c in n["children"]],
        }
    return [_clean(r) for r in roots]


@router.get("/api/integrations/google/labels")
def api_google_labels():
    """Return Gmail label tree for folder selection."""
    if not GOOGLE_API_SUPPORT:
        return {"ok": False, "error": "Google API libraries not installed."}
    creds = _google_get_credentials()
    if not creds:
        return {"ok": False, "error": "Not authorized. Click 'Authorize Google' first."}
    try:
        service = _google_service("gmail", "v1")
        if not service:
            return {"ok": False, "error": "Could not build Gmail service."}
        result = service.users().labels().list(userId="me").execute()
        labels = result.get("labels", [])
        # Fetch message counts per label
        enriched: list[dict] = []
        for lbl in labels:
            try:
                detail = service.users().labels().get(userId="me", id=lbl["id"]).execute()
                enriched.append(detail)
            except Exception:
                enriched.append(lbl)
        tree = _build_gmail_label_tree(enriched)
        profile = service.users().getProfile(userId="me").execute()
        email = profile.get("emailAddress", "Gmail")
        return {"ok": True, "stores": [{"store": email, "folders": tree}]}
    except Exception as e:
        return {"ok": False, "error": f"Gmail API error: {e}"}


# ── Auto-describe folder by reading recent emails ────────────────────────────

import collections as _collections


def _extract_folder_topics(subjects: list[str], max_keywords: int = 8) -> str:
    """Extract topic keywords from a list of email subjects."""
    _stopwords = {
        "re", "fw", "fwd", "the", "and", "for", "with", "from", "your", "you",
        "this", "that", "have", "has", "been", "will", "are", "was", "were",
        "not", "but", "can", "all", "our", "new", "het", "een", "van", "voor",
        "met", "die", "dat", "ook", "nog", "aan", "bij", "naar", "uit", "over",
        "wel", "als", "dan", "wat", "hoe", "wie", "waar", "deze", "uw", "je",
        "jouw", "mijn", "wij", "zij", "hun", "ons", "hem",
    }
    words: _collections.Counter = _collections.Counter()
    for subj in subjects:
        clean = re.sub(r"^(re|fw|fwd)\s*:\s*", "", str(subj or ""), flags=re.IGNORECASE).strip()
        for token in re.findall(r"[A-Za-zÀ-ÿ]{3,}", clean):
            lower = token.lower()
            if lower not in _stopwords and len(lower) >= 3:
                words[lower] += 1
    if not words:
        return ""
    top = [w for w, _ in words.most_common(max_keywords)]
    return ", ".join(top)


def _outlook_resolve_folder_by_path(ns, folder_path: str):
    """Walk Outlook COM folder tree to resolve a '/'-separated path like 'store/Folder/Sub'."""
    parts = [p for p in folder_path.replace("\\", "/").split("/") if p.strip()]
    if not parts:
        return None
    stores = getattr(ns, "Stores", None)
    if stores is None:
        return None
    for i in range(1, int(stores.Count) + 1):
        try:
            store = stores.Item(i)
            display = str(getattr(store, "DisplayName", "") or "").strip()
            root = store.GetRootFolder()
            # Try matching from the store root
            # Path may start with store name or not
            search_parts = parts
            if display and parts[0].lower() == display.lower():
                search_parts = parts[1:]
            folder = root
            for part in search_parts:
                found = False
                subfolders = folder.Folders
                for j in range(1, int(subfolders.Count) + 1):
                    sub = subfolders.Item(j)
                    if str(getattr(sub, "Name", "")).lower() == part.lower():
                        folder = sub
                        found = True
                        break
                if not found:
                    folder = None
                    break
            if folder and folder != root:
                return folder
        except Exception:
            continue
    return None


@router.get("/api/integrations/outlook/folder-describe")
def api_outlook_folder_describe(path: str = ""):
    """Read recent emails from an Outlook folder and return auto-generated topic keywords."""
    if not OUTLOOK_SUPPORT:
        return {"ok": False, "error": "pywin32 not installed."}
    folder_path = (path or "").strip()
    if not folder_path:
        return {"ok": False, "error": "No folder path provided."}
    try:
        with _outlook_session() as (_, ns):
            folder_obj = _outlook_resolve_folder_by_path(ns, folder_path)
            if not folder_obj:
                return {"ok": False, "error": f"Could not find folder: {folder_path}"}
            items = folder_obj.Items
            items.Sort("[ReceivedTime]", True)
            subjects: list[str] = []
            total = min(int(items.Count), 30)
            for idx in range(1, total + 1):
                try:
                    msg = items.Item(idx)
                    subj = str(getattr(msg, "Subject", "") or "").strip()
                    if subj:
                        subjects.append(subj)
                except Exception:
                    pass
        description = _extract_folder_topics(subjects)
        return {"ok": True, "description": description, "sample_count": len(subjects)}
    except Exception as e:
        return {"ok": False, "error": str(e)}


@router.get("/api/integrations/google/label-describe")
def api_google_label_describe(path: str = ""):
    """Read recent emails from a Gmail label and return auto-generated topic keywords."""
    if not GOOGLE_API_SUPPORT:
        return {"ok": False, "error": "Google API libraries not installed."}
    creds = _google_get_credentials()
    if not creds:
        return {"ok": False, "error": "Not authorized."}
    label_path = (path or "").strip()
    if not label_path:
        return {"ok": False, "error": "No label path provided."}
    try:
        service = _google_service("gmail", "v1")
        if not service:
            return {"ok": False, "error": "Could not build Gmail service."}
        # Resolve label ID from name
        all_labels = service.users().labels().list(userId="me").execute().get("labels", [])
        label_id = None
        for lbl in all_labels:
            if (lbl.get("name") or "").lower() == label_path.lower():
                label_id = lbl["id"]
                break
        if not label_id:
            return {"ok": False, "error": f"Label not found: {label_path}"}
        resp = service.users().messages().list(
            userId="me", labelIds=[label_id], maxResults=30,
        ).execute()
        messages = resp.get("messages", [])
        subjects: list[str] = []
        for msg_stub in messages:
            try:
                msg = service.users().messages().get(
                    userId="me", id=msg_stub["id"], format="metadata",
                    metadataHeaders=["Subject"],
                ).execute()
                hdrs = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
                subj = hdrs.get("Subject", "").strip()
                if subj:
                    subjects.append(subj)
            except Exception:
                pass
        description = _extract_folder_topics(subjects)
        return {"ok": True, "description": description, "sample_count": len(subjects)}
    except Exception as e:
        return {"ok": False, "error": f"Gmail API error: {e}"}


# ── Google OAuth2 routes ──────────────────────────────────────────────────────
_google_code_verifier: str = ""


@router.get("/api/integrations/google/auth")
def api_google_auth():
    global _google_code_verifier
    if not GOOGLE_API_SUPPORT:
        return {"ok": False, "error": "Google API libraries not installed. Run: pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib"}
    s = _load_settings()
    client_id = (s.get("google_client_id") or "").strip()
    client_secret = (s.get("google_client_secret") or "").strip()
    if not client_id or not client_secret:
        return {"ok": False, "error": "Enter your Client ID and Client Secret first."}
    if not client_id.endswith(".apps.googleusercontent.com"):
        return {"ok": False, "error": "Client ID does not look valid. It should end with .apps.googleusercontent.com"}
    try:
        flow = _google_build_flow(s)
        auth_url, _ = flow.authorization_url(access_type="offline", prompt="consent")
        _google_code_verifier = flow.code_verifier or ""
        return {"ok": True, "auth_url": auth_url}
    except Exception as e:
        return {"ok": False, "error": str(e)}


@router.get("/api/integrations/google/callback")
def api_google_callback(request: Request):
    global _google_code_verifier
    if not GOOGLE_API_SUPPORT:
        return {"ok": False, "error": "Google API libraries not installed."}
    code = request.query_params.get("code", "")
    error = request.query_params.get("error", "")
    if error:
        return HTMLResponse(f"<html><body><h2>Authorization failed</h2><p>{error}</p><p>You can close this window.</p></body></html>")
    if not code:
        return HTMLResponse("<html><body><h2>Missing authorization code</h2><p>You can close this window.</p></body></html>")
    try:
        s = _load_settings()
        flow = _google_build_flow(s)
        flow.code_verifier = _google_code_verifier
        flow.fetch_token(code=code)
        _google_code_verifier = ""
        creds = flow.credentials
        token_data = json.loads(creds.to_json())
        token_data["scopes"] = GOOGLE_SCOPES
        GOOGLE_TOKEN_FILE.write_text(json.dumps(token_data), encoding="utf-8")
        return HTMLResponse(
            "<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
            "<h2 style='color:#22c55e'>&#10003; Google authorized successfully</h2>"
            "<p>You can close this window and return to AgentOrchestrator.</p>"
            "<script>window.close()</script></body></html>"
        )
    except Exception as e:
        return HTMLResponse(
            f"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
            f"<h2 style='color:#ef4444'>Authorization failed</h2>"
            f"<p>{e}</p><p>You can close this window.</p></body></html>"
        )


@router.get("/api/integrations/google/status")
def api_google_status():
    s = _load_settings()
    enabled = bool(s.get("google_enabled"))
    has_client_id = bool((s.get("google_client_id") or "").strip())
    has_client_secret = bool((s.get("google_client_secret") or "").strip())
    creds = _google_get_credentials()
    return {
        "enabled": enabled,
        "google_api_installed": GOOGLE_API_SUPPORT,
        "has_credentials": has_client_id and has_client_secret,
        "authorized": creds is not None,
        "token_file_exists": GOOGLE_TOKEN_FILE.exists(),
        "scopes_ok": _google_has_all_scopes(),
    }


@router.get("/api/integrations/google/test")
def api_google_test():
    s = _load_settings()
    client_id     = (s.get("google_client_id") or "").strip()
    client_secret = (s.get("google_client_secret") or "").strip()
    if not client_id or not client_secret:
        return {"ok": False, "error": "No Google credentials saved. Enter your Client ID and Client Secret first."}
    if not client_id.endswith(".apps.googleusercontent.com"):
        return {"ok": False, "error": "Client ID does not look valid. It should end with .apps.googleusercontent.com"}
    if not GOOGLE_API_SUPPORT:
        return {"ok": False, "error": "Google API libraries not installed. Run: pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib"}
    creds = _google_get_credentials()
    if not creds:
        if GOOGLE_TOKEN_FILE.exists():
            return {"ok": False, "error": "Token expired or invalid. Click 'Authorize Google' to re-authorize."}
        return {"ok": False, "error": "Not yet authorized. Click 'Authorize Google' to complete the OAuth flow."}
    try:
        service = _google_service("gmail", "v1")
        if not service:
            return {"ok": False, "error": "Could not build Gmail service."}
        profile = service.users().getProfile(userId="me").execute()
        email = profile.get("emailAddress", "unknown")
        return {"ok": True, "message": f"Connected to Gmail as {email}."}
    except Exception as e:
        return {"ok": False, "error": f"Gmail API error: {e}"}


# ── Microsoft Graph OAuth2 routes ─────────────────────────────────────────────
import secrets as _secrets
_microsoft_state: str = ""


@router.get("/api/integrations/microsoft/auth")
def api_microsoft_auth():
    global _microsoft_state
    s = _load_settings()
    client_id = (s.get("microsoft_client_id") or "").strip()
    client_secret = (s.get("microsoft_client_secret") or "").strip()
    if not client_id or not client_secret:
        return {"ok": False, "error": "Enter your Microsoft Client ID and Client Secret first."}
    _microsoft_state = _secrets.token_urlsafe(32)
    from urllib.parse import urlencode
    params = urlencode({
        "client_id": client_id,
        "response_type": "code",
        "redirect_uri": "http://127.0.0.1:8000/api/integrations/microsoft/callback",
        "response_mode": "query",
        "scope": MICROSOFT_SCOPES,
        "state": _microsoft_state,
    })
    auth_url = f"{MICROSOFT_AUTH_URL}?{params}"
    return {"ok": True, "auth_url": auth_url}


@router.get("/api/integrations/microsoft/callback")
def api_microsoft_callback(request: Request):
    global _microsoft_state
    code = request.query_params.get("code", "")
    error = request.query_params.get("error", "")
    state = request.query_params.get("state", "")
    if error:
        desc = request.query_params.get("error_description", error)
        return HTMLResponse(
            f"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
            f"<h2 style='color:#ef4444'>Authorization failed</h2>"
            f"<p>{desc}</p><p>You can close this window.</p></body></html>"
        )
    if not code:
        return HTMLResponse("<html><body><h2>Missing authorization code</h2><p>You can close this window.</p></body></html>")
    if _microsoft_state and state != _microsoft_state:
        return HTMLResponse("<html><body><h2>State mismatch — possible CSRF</h2><p>You can close this window and try again.</p></body></html>")
    _microsoft_state = ""
    try:
        s = _load_settings()
        resp = requests.post(MICROSOFT_TOKEN_URL, data={
            "client_id": (s.get("microsoft_client_id") or "").strip(),
            "client_secret": (s.get("microsoft_client_secret") or "").strip(),
            "code": code,
            "redirect_uri": "http://127.0.0.1:8000/api/integrations/microsoft/callback",
            "grant_type": "authorization_code",
            "scope": MICROSOFT_SCOPES,
        }, timeout=15)
        resp.raise_for_status()
        token_data = resp.json()
        token_data["expires_at"] = __import__("time").time() + token_data.get("expires_in", 3600)
        MICROSOFT_TOKEN_FILE.write_text(json.dumps(token_data), encoding="utf-8")
        return HTMLResponse(
            "<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
            "<h2 style='color:#22c55e'>&#10003; Microsoft authorized successfully</h2>"
            "<p>You can close this window and return to AgentOrchestrator.</p>"
            "<script>window.close()</script></body></html>"
        )
    except Exception as e:
        return HTMLResponse(
            f"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
            f"<h2 style='color:#ef4444'>Authorization failed</h2>"
            f"<p>{e}</p><p>You can close this window.</p></body></html>"
        )


@router.get("/api/integrations/microsoft/status")
def api_microsoft_status():
    s = _load_settings()
    enabled = bool(s.get("microsoft_enabled"))
    has_client_id = bool((s.get("microsoft_client_id") or "").strip())
    has_client_secret = bool((s.get("microsoft_client_secret") or "").strip())
    token = _microsoft_get_token()
    mode = s.get("onedrive_mode", "local")
    local_path = (s.get("onedrive_local_path") or "").strip()
    local_ok = bool(local_path) and Path(local_path).is_dir()
    return {
        "enabled": enabled,
        "has_credentials": has_client_id and has_client_secret,
        "authorized": token is not None,
        "token_file_exists": MICROSOFT_TOKEN_FILE.exists(),
        "onedrive_mode": mode,
        "onedrive_local_path": local_path if local_ok else "",
        "onedrive_local_ok": local_ok,
    }


@router.get("/api/integrations/microsoft/detect-local")
def api_microsoft_detect_local():
    """Auto-detect the local OneDrive sync folder."""
    detected = _detect_onedrive_local_path()
    return {"path": detected, "exists": bool(detected)}


@router.get("/api/cloud-browse/onedrive-local")
def api_cloud_browse_onedrive_local(folder_path: str = ""):
    """Browse the local OneDrive sync folder like a normal directory."""
    s = _load_settings()
    base = (s.get("onedrive_local_path") or "").strip()
    if not base or not Path(base).is_dir():
        return JSONResponse({"error": "OneDrive local path not configured or not found"}, status_code=400)
    base_p = Path(base).resolve()
    if folder_path:
        target = (base_p / folder_path).resolve()
    else:
        target = base_p
    # Security: ensure we stay within the OneDrive folder
    if not str(target).startswith(str(base_p)):
        return JSONResponse({"error": "Path outside OneDrive folder"}, status_code=403)
    if not target.is_dir():
        return JSONResponse({"error": "Folder not found"}, status_code=404)
    items = []
    try:
        for entry in sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
            if entry.name.startswith("."):
                continue
            is_folder = entry.is_dir()
            rel = str(entry.relative_to(base_p)).replace("\\", "/")
            size = 0
            modified = ""
            if not is_folder:
                try:
                    stat = entry.stat()
                    size = stat.st_size
                    modified = datetime.fromtimestamp(stat.st_mtime).isoformat()
                except OSError:
                    pass
            items.append({
                "id": rel,
                "name": entry.name,
                "is_folder": is_folder,
                "mime": "",
                "size": size,
                "modified": modified,
            })
    except PermissionError:
        return JSONResponse({"error": "Permission denied"}, status_code=403)
    return {"items": items, "folder_path": folder_path or ""}


@router.get("/api/integrations/microsoft/test")
def api_microsoft_test():
    s = _load_settings()
    client_id = (s.get("microsoft_client_id") or "").strip()
    client_secret = (s.get("microsoft_client_secret") or "").strip()
    if not client_id or not client_secret:
        return {"ok": False, "error": "No Microsoft credentials saved. Enter your Client ID and Client Secret first."}
    token = _microsoft_get_token()
    if not token:
        if MICROSOFT_TOKEN_FILE.exists():
            return {"ok": False, "error": "Token expired or invalid. Click 'Authorize Microsoft' to re-authorize."}
        return {"ok": False, "error": "Not yet authorized. Click 'Authorize Microsoft' to complete the OAuth flow."}
    try:
        resp = requests.get(
            f"{MICROSOFT_GRAPH_URL}/me",
            headers={"Authorization": f"Bearer {token}"},
            timeout=15,
        )
        resp.raise_for_status()
        profile = resp.json()
        name = profile.get("displayName", "unknown")
        email = profile.get("mail") or profile.get("userPrincipalName", "unknown")
        return {"ok": True, "message": f"Connected as {name} ({email})."}
    except Exception as e:
        return {"ok": False, "error": f"Microsoft Graph API error: {e}"}


# ── Slack status routes ───────────────────────────────────────────────────────
@router.get("/api/integrations/slack/status")
def api_slack_status():
    connected = _slack_handler is not None
    s = _load_settings()
    return {
        "enabled": bool(s.get("slack_enabled")),
        "socket_mode_connected": connected,
        "has_bot_token": bool((s.get("slack_bot_token") or "").strip()),
        "has_app_token": bool((s.get("slack_app_token") or "").strip()),
        "bolt_installed": SLACK_BOLT_SUPPORT,
    }


@router.get("/api/integrations/slack/test")
def api_slack_test():
    s = _load_settings()
    token = (s.get("slack_bot_token") or "").strip()
    if not token:
        return {"ok": False, "error": "No Slack Bot Token saved. Enter your xoxb-… token first."}
    try:
        resp = requests.post(
            "https://slack.com/api/auth.test",
            headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
            timeout=8,
        )
        data = resp.json()
        if data.get("ok"):
            team = data.get("team", "unknown workspace")
            user = data.get("user", "unknown bot")
            return {"ok": True, "message": f"Connected as '{user}' in workspace '{team}'."}
        return {"ok": False, "error": data.get("error", "auth.test returned ok:false")}
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ── Schedules REST API ────────────────────────────────────────────────────────
@router.get("/api/schedules")
def api_schedules_list():
    return {"schedules": _load_schedules()}


@router.post("/api/schedules")
async def api_schedules_create(req: Request):
    body = await req.json()
    goal = (body.get("goal") or "").strip()
    interval_raw = (body.get("interval") or "").strip().lower()
    source = str(body.get("source") or "schedule").strip() or "schedule"
    label = str(body.get("label") or "").strip()
    metadata = body.get("metadata") if isinstance(body.get("metadata"), dict) else {}
    enabled = bool(body.get("enabled", True))
    if not goal:
        return JSONResponse({"ok": False, "error": "Missing 'goal'."}, status_code=400)
    if not interval_raw:
        return JSONResponse({"ok": False, "error": "Missing 'interval'."}, status_code=400)
    m = re.fullmatch(r"(\d+)\s*(m|h|d|w|mo|M)", interval_raw)
    if not m:
        return JSONResponse({"ok": False, "error": "Invalid interval. Use e.g. 30m, 2h, 1d, 1w, 1mo."}, status_code=400)
    amount = int(m.group(1))
    unit = m.group(2)
    multiplier = {"m": 60, "h": 3600, "d": 86400, "w": 604800, "mo": 2592000, "M": 2592000}[unit]
    interval_seconds = amount * multiplier
    if interval_seconds < 60:
        return JSONResponse({"ok": False, "error": "Minimum interval is 1m."}, status_code=400)
    # Optional first-run datetime (ISO string from the client)
    first_run_raw = (body.get("first_run") or "").strip()
    if first_run_raw:
        try:
            first_run_dt = datetime.fromisoformat(first_run_raw)
        except ValueError:
            return JSONResponse({"ok": False, "error": "Invalid first_run datetime."}, status_code=400)
        next_run = first_run_dt.isoformat()
    else:
        next_run = (datetime.now() + timedelta(seconds=interval_seconds)).isoformat()
    entry = {
        "id": str(uuid.uuid4())[:8],
        "goal": goal,
        "interval_seconds": interval_seconds,
        "interval_label": interval_raw,
        "next_run": next_run,
        "created_at": datetime.now().isoformat(),
        "enabled": enabled,
        "source": source,
        "label": label,
        "metadata": metadata,
    }
    scheds = _load_schedules()
    scheds.append(entry)
    _save_schedules(scheds)
    return {"ok": True, "schedule": entry}


@router.delete("/api/schedules/{schedule_id}")
def api_schedules_delete(schedule_id: str):
    scheds = _load_schedules()
    before = len(scheds)
    scheds = [s for s in scheds if s["id"] != schedule_id]
    if len(scheds) == before:
        return JSONResponse({"ok": False, "error": "Schedule not found."}, status_code=404)
    _save_schedules(scheds)
    return {"ok": True, "deleted": schedule_id}


@router.patch("/api/schedules/{schedule_id}")
async def api_schedules_update(schedule_id: str, req: Request):
    body = await req.json()
    scheds = _load_schedules()
    idx = next((i for i, s in enumerate(scheds) if s["id"] == schedule_id), None)
    if idx is None:
        return JSONResponse({"ok": False, "error": "Schedule not found."}, status_code=404)
    new_goal = (body.get("goal") or "").strip()
    if new_goal:
        scheds[idx]["goal"] = new_goal
    if "source" in body:
        scheds[idx]["source"] = str(body.get("source") or "schedule").strip() or "schedule"
    if "label" in body:
        scheds[idx]["label"] = str(body.get("label") or "").strip()
    if "metadata" in body and isinstance(body.get("metadata"), dict):
        scheds[idx]["metadata"] = body.get("metadata") or {}
    if "enabled" in body:
        scheds[idx]["enabled"] = bool(body["enabled"])
        # Re-arm next_run when re-enabling
        if bool(body["enabled"]) and scheds[idx].get("interval_seconds", 0) > 0:
            scheds[idx]["next_run"] = (
                datetime.now() + timedelta(seconds=scheds[idx]["interval_seconds"])
            ).isoformat()
    interval_raw = (body.get("interval") or "").strip().lower()
    if interval_raw:
        m = re.fullmatch(r"(\d+)\s*(m|h|d|w|mo|M)", interval_raw)
        if not m:
            return JSONResponse({"ok": False, "error": "Invalid interval. Use e.g. 30m, 2h, 1d, 1w, 1mo."}, status_code=400)
        amount = int(m.group(1))
        unit_char = m.group(2)
        multiplier = {"m": 60, "h": 3600, "d": 86400, "w": 604800, "mo": 2592000, "M": 2592000}[unit_char]
        interval_seconds = amount * multiplier
        if interval_seconds < 60:
            return JSONResponse({"ok": False, "error": "Minimum interval is 1m."}, status_code=400)
        scheds[idx]["interval_seconds"] = interval_seconds
        scheds[idx]["interval_label"] = interval_raw
        scheds[idx]["next_run"] = (datetime.now() + timedelta(seconds=interval_seconds)).isoformat()
    # Allow explicit next_run override (e.g. user edits first-run datetime)
    next_run_raw = (body.get("next_run") or "").strip()
    if next_run_raw:
        try:
            datetime.fromisoformat(next_run_raw)
            scheds[idx]["next_run"] = next_run_raw
        except ValueError:
            return JSONResponse({"ok": False, "error": "Invalid next_run datetime."}, status_code=400)
    _save_schedules(scheds)
    return {"ok": True, "schedule": scheds[idx]}


# ── Folder picker / file upload ───────────────────────────────────────────────
@router.get("/api/pick-folder")
async def api_pick_folder():
    import tkinter as tk
    from tkinter import filedialog
    loop = asyncio.get_event_loop()

    def _open_dialog():
        root = tk.Tk()
        root.withdraw()
        root.wm_attributes('-topmost', True)
        path = filedialog.askdirectory(parent=root, title='Select folder')
        root.destroy()
        return path

    selected = await loop.run_in_executor(None, _open_dialog)
    if not selected:
        return {"cancelled": True, "path": ""}
    return {"cancelled": False, "path": selected}


@router.get("/api/folder-index")
async def api_folder_index(path: str):
    TEXT_EXTS = {
        '.txt','.md','.py','.js','.ts','.jsx','.tsx','.html','.css',
        '.json','.yaml','.yml','.xml','.sql','.sh','.bat','.ps1',
        '.cpp','.c','.h','.java','.go','.rs','.rb','.php','.csv',
        '.log','.ini','.cfg','.env','.toml','.swift','.kt','.dart',
        '.rs','.scala','.lua','.r','.m','.vbs','.awk','.sed',
    }
    SKIP_DIRS = {
        'node_modules','.git','__pycache__','.venv','venv',
        'dist','build','.next','.svn','vendor','.mypy_cache',
        '.pytest_cache','target','out','bin','obj',
    }
    MAX_FILE_BYTES = 50 * 1024 * 1024
    root = Path(path)
    if not root.exists() or not root.is_dir():
        return {"error": f"Path not found or not a directory: {path}", "files": []}
    files: list[dict] = []
    for p in sorted(root.rglob('*')):
        if not p.is_file():
            continue
        parts = p.relative_to(root).parts
        if any(seg in SKIP_DIRS for seg in parts):
            continue
        size = p.stat().st_size
        if size > MAX_FILE_BYTES:
            continue
        ext = p.suffix.lower()
        readable = ext in TEXT_EXTS or ext == ''
        files.append({"path": str(p), "rel": str(p.relative_to(root)), "size": size, "readable": readable})
    return {"root": str(root), "file_count": len(files), "files": files}


@router.post("/api/upload")
async def api_upload(file: UploadFile = File(...)):
    parts = []
    for f in [file]:
        data = await f.read()
        mime = f.content_type or ""
        if str(mime or "").lower().startswith("image/"):
            stored_path = _store_chat_upload(data, f.filename or "upload")
            parts.append(_format_uploaded_image_context(f.filename or "upload", mime, stored_path, len(data)))
        else:
            parts.append(extract_uploaded_bytes(data, f.filename or "upload", mime_type=mime, max_chars=50000))
    return {"extracted_text": "\n\n".join(parts)}


# ── Cloud file browser endpoints ──────────────────────────────────────────────

@router.get("/api/cloud-browse/google-drive")
def api_cloud_browse_google_drive(folder_id: str = "root"):
    """Return JSON list of files/folders in a Google Drive folder for the file browser."""
    if not GOOGLE_API_SUPPORT:
        return JSONResponse({"error": "Google API not available"}, status_code=503)
    service = _google_service("drive", "v3")
    if not service:
        return JSONResponse({"error": "Google not authorized"}, status_code=401)
    try:
        q = f"'{folder_id}' in parents and trashed = false"
        resp = service.files().list(
            q=q, pageSize=100,
            fields="files(id, name, mimeType, size, modifiedTime)",
            orderBy="folder,name",
        ).execute()
        items = []
        for f in resp.get("files", []):
            is_folder = f.get("mimeType") == "application/vnd.google-apps.folder"
            items.append({
                "id": f["id"],
                "name": f["name"],
                "is_folder": is_folder,
                "mime": f.get("mimeType", ""),
                "size": int(f["size"]) if f.get("size") else 0,
                "modified": f.get("modifiedTime", ""),
            })
        return {"items": items, "folder_id": folder_id}
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)


@router.get("/api/cloud-browse/onedrive")
def api_cloud_browse_onedrive(folder_path: str = "", folder_id: str = ""):
    """Return JSON list of files/folders in a OneDrive folder for the file browser."""
    headers = _microsoft_graph_headers()
    if not headers:
        return JSONResponse({"error": "Microsoft not authorized"}, status_code=401)
    try:
        if folder_id:
            url = f"{MICROSOFT_GRAPH_URL}/me/drive/items/{folder_id}/children"
        elif folder_path and folder_path != "/":
            clean = folder_path.strip("/")
            url = f"{MICROSOFT_GRAPH_URL}/me/drive/root:/{clean}:/children"
        else:
            url = f"{MICROSOFT_GRAPH_URL}/me/drive/root/children"
        resp = requests.get(url, headers=headers, params={"$top": 200}, timeout=15)
        resp.raise_for_status()
        items = []
        for item in resp.json().get("value", []):
            is_folder = "folder" in item
            items.append({
                "id": item["id"],
                "name": item["name"],
                "is_folder": is_folder,
                "mime": item.get("file", {}).get("mimeType", "") if not is_folder else "",
                "size": item.get("size", 0),
                "modified": item.get("lastModifiedDateTime", ""),
            })
        return {"items": items, "folder_id": folder_id, "folder_path": folder_path}
    except Exception as e:
        return JSONResponse({"error": str(e)}, status_code=500)


@router.get("/api/cloud-read")
def api_cloud_read(provider: str, file_id: str):
    """Download a cloud file's text content for attaching as context."""
    if provider == "google_drive":
        if not GOOGLE_API_SUPPORT:
            return JSONResponse({"error": "Google API not available"}, status_code=503)
        service = _google_service("drive", "v3")
        if not service:
            return JSONResponse({"error": "Google not authorized"}, status_code=401)
        try:
            meta = service.files().get(fileId=file_id, fields="name,mimeType,size").execute()
            name = meta.get("name", "unknown")
            mime = meta.get("mimeType", "")
            export_map = {
                "application/vnd.google-apps.document": "text/plain",
                "application/vnd.google-apps.spreadsheet": "text/csv",
                "application/vnd.google-apps.presentation": "text/plain",
            }
            export_mime = export_map.get(mime)
            if export_mime:
                content = service.files().export(fileId=file_id, mimeType=export_mime).execute()
                if isinstance(content, bytes):
                    content = content.decode("utf-8", errors="replace")
                return {"name": name, "text": content[:50000]}
            content = service.files().get_media(fileId=file_id).execute()
            if isinstance(content, bytes):
                return {"name": name, "text": extract_uploaded_bytes(content, name, mime_type=mime, max_chars=50000)}
            return {"name": name, "text": str(content)[:50000]}
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    elif provider == "onedrive":
        headers = _microsoft_graph_headers()
        if not headers:
            return JSONResponse({"error": "Microsoft not authorized"}, status_code=401)
        try:
            meta_resp = requests.get(
                f"{MICROSOFT_GRAPH_URL}/me/drive/items/{file_id}",
                headers=headers, timeout=15,
            )
            meta_resp.raise_for_status()
            meta = meta_resp.json()
            name = meta.get("name", "unknown")
            dl_url = meta.get("@microsoft.graph.downloadUrl", "")
            if not dl_url:
                dl_url = f"{MICROSOFT_GRAPH_URL}/me/drive/items/{file_id}/content"
            content_resp = requests.get(dl_url, timeout=30)
            content_resp.raise_for_status()
            mime = meta.get("file", {}).get("mimeType", "")
            return {"name": name, "text": extract_uploaded_bytes(content_resp.content, name, mime_type=mime, max_chars=50000)}
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    elif provider == "onedrive_local":
        s = _load_settings()
        base = (s.get("onedrive_local_path") or "").strip()
        if not base or not Path(base).is_dir():
            return JSONResponse({"error": "OneDrive local path not configured"}, status_code=400)
        base_p = Path(base).resolve()
        target = (base_p / file_id).resolve()
        if not str(target).startswith(str(base_p)):
            return JSONResponse({"error": "Path outside OneDrive folder"}, status_code=403)
        if not target.is_file():
            return JSONResponse({"error": "File not found"}, status_code=404)
        try:
            text = target.read_text(encoding="utf-8", errors="replace")
            return {"name": target.name, "text": text[:50000]}
        except Exception as e:
            return JSONResponse({"error": str(e)}, status_code=500)

    return JSONResponse({"error": f"Unknown provider: {provider}"}, status_code=400)


# ── Schedule failure endpoints ─────────────────────────────────────────────────
@router.get("/api/schedule/failures")
async def get_schedule_failures():
    return {"failures": _get_unacked_failures()}


@router.post("/api/schedule/failures/ack")
async def ack_schedule_failures():
    _ack_all_failures()
    return {"ok": True}
