"""
web/routes.py — REST API routes for credentials, audit log, agents, settings, etc.
All database calls are async (asyncpg).
"""
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import Optional

from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel

from ..agents import tasks as agent_store
from ..agents.runner import agent_runner
from ..audit import audit_log
from ..config import settings
from ..database import credential_store, email_whitelist_store, filesystem_whitelist_store, web_whitelist_store

router = APIRouter()


# ── Credentials ───────────────────────────────────────────────────────────────

class CredentialIn(BaseModel):
    key: str
    value: str
    description: str = ""


@router.get("/credentials")
async def list_credentials(request: Request):
    _require_admin(request)
    return await credential_store.list_keys()


@router.get("/credentials/{key:path}")
async def get_credential(request: Request, key: str):
    _require_admin(request)
    value = await credential_store.get(key)
    if value is None:
        raise HTTPException(status_code=404, detail=f"Credential '{key}' not found.")
    return {"key": key, "value": value}


@router.post("/credentials")
async def set_credential(request: Request, body: CredentialIn):
    _require_admin(request)
    await credential_store.set(body.key, body.value, body.description)
    return {"ok": True, "key": body.key}


@router.delete("/credentials/{key:path}")
async def delete_credential(request: Request, key: str):
    _require_admin(request)
    if key == "system:paused":
        raise HTTPException(status_code=400, detail="Cannot delete kill-switch key via this endpoint.")
    deleted = await credential_store.delete(key)
    if not deleted:
        raise HTTPException(status_code=404, detail=f"Credential '{key}' not found.")
    return {"ok": True, "key": key}


# ── Audit log ─────────────────────────────────────────────────────────────────

@router.get("/audit")
async def query_audit(
    request: Request,
    start: str = "",
    end: str = "",
    tool_name: str = "",
    session_id: str = "",
    task_id: str = "",
    confirmed_only: bool = False,
    page: int = 1,
    per_page: int = 50,
):
    user = _require_auth(request)
    # Non-admins can only see their own audit entries
    force_user_id = None if user.is_admin else user.id
    offset = (page - 1) * per_page
    entries, total = await asyncio_gather(
        audit_log.query(
            start=start or None,
            end=end or None,
            tool_name=tool_name or None,
            session_id=session_id or None,
            task_id=task_id or None,
            confirmed_only=confirmed_only,
            user_id=force_user_id,
            limit=per_page,
            offset=offset,
        ),
        audit_log.count(
            start=start or None,
            end=end or None,
            tool_name=tool_name or None,
            task_id=task_id or None,
            session_id=session_id or None,
            confirmed_only=confirmed_only,
            user_id=force_user_id,
        ),
    )
    return {
        "entries": [
            {
                "id": e.id,
                "timestamp": e.timestamp,
                "session_id": e.session_id,
                "tool_name": e.tool_name,
                "arguments": e.arguments,
                "result_summary": e.result_summary,
                "confirmed": e.confirmed,
                "task_id": e.task_id,
            }
            for e in entries
        ],
        "total": total,
        "page": page,
        "per_page": per_page,
        "pages": max(1, -(-total // per_page)),
    }


@router.delete("/audit")
async def clear_audit(request: Request, older_than_days: int = 0):
    _require_admin(request)
    deleted = await audit_log.purge(older_than_days=older_than_days if older_than_days > 0 else None)
    return {"deleted": deleted}


@router.get("/settings/audit-retention")
async def get_audit_retention(request: Request):
    _require_admin(request)
    days = await credential_store.get("system:audit_retention_days")
    return {"days": int(days) if days else 0}


@router.post("/settings/audit-retention")
async def set_audit_retention(request: Request, body: dict):
    _require_admin(request)
    days = int(body.get("days", 0))
    if days > 0:
        await credential_store.set(
            "system:audit_retention_days", str(days),
            description="Audit log retention in days (0 = keep forever)",
        )
    else:
        await credential_store.delete("system:audit_retention_days")
    return {"days": days}


# ── Email whitelist ────────────────────────────────────────────────────────────

class EmailWhitelistEntry(BaseModel):
    email: str
    daily_limit: int = 0


@router.get("/email-whitelist")
async def list_email_whitelist(request: Request):
    _require_admin(request)
    return await email_whitelist_store.list()


@router.post("/email-whitelist")
async def add_email_whitelist(request: Request, body: EmailWhitelistEntry):
    _require_admin(request)
    email = body.email.strip().lower()
    if not email or "@" not in email:
        raise HTTPException(status_code=400, detail="Invalid email address")
    if body.daily_limit < 0:
        raise HTTPException(status_code=400, detail="daily_limit must be >= 0")
    await email_whitelist_store.add(email, body.daily_limit)
    return {"ok": True, "email": email, "daily_limit": body.daily_limit}


@router.delete("/email-whitelist/{email:path}")
async def delete_email_whitelist(request: Request, email: str):
    _require_admin(request)
    deleted = await email_whitelist_store.remove(email)
    if not deleted:
        raise HTTPException(status_code=404, detail=f"Email '{email}' not in whitelist")
    return {"ok": True, "email": email}


# ── Web whitelist ─────────────────────────────────────────────────────────────

class WebWhitelistEntry(BaseModel):
    domain: str
    note: str = ""


@router.get("/web-whitelist")
async def list_web_whitelist(request: Request):
    _require_admin(request)
    return await web_whitelist_store.list()


@router.post("/web-whitelist")
async def add_web_whitelist(request: Request, body: WebWhitelistEntry):
    _require_admin(request)
    domain = body.domain.strip()
    if not domain:
        raise HTTPException(status_code=400, detail="domain is required")
    await web_whitelist_store.add(domain, body.note)
    return {"ok": True, "domain": domain}


@router.delete("/web-whitelist/{domain:path}")
async def delete_web_whitelist(request: Request, domain: str):
    _require_admin(request)
    deleted = await web_whitelist_store.remove(domain)
    if not deleted:
        raise HTTPException(status_code=404, detail=f"Domain '{domain}' not in whitelist")
    return {"ok": True, "domain": domain}


# ── Filesystem whitelist ──────────────────────────────────────────────────────

class FilesystemWhitelistEntry(BaseModel):
    path: str
    note: str = ""


@router.get("/filesystem-whitelist")
async def list_filesystem_whitelist(request: Request):
    _require_admin(request)
    return await filesystem_whitelist_store.list()


@router.post("/filesystem-whitelist")
async def add_filesystem_whitelist(request: Request, body: FilesystemWhitelistEntry):
    _require_admin(request)
    from pathlib import Path
    path = body.path.strip()
    if not path:
        raise HTTPException(status_code=400, detail="path is required")
    if not Path(path).is_absolute():
        raise HTTPException(status_code=400, detail="path must be absolute (e.g. /home/rune/documents)")
    if not Path(path).exists():
        raise HTTPException(status_code=400, detail=f"path does not exist: {path}")
    if not Path(path).is_dir():
        raise HTTPException(status_code=400, detail=f"path is not a directory: {path}")
    await filesystem_whitelist_store.add(path, body.note)
    return {"ok": True, "path": path}


@router.delete("/filesystem-whitelist/{path:path}")
async def delete_filesystem_whitelist(request: Request, path: str):
    _require_admin(request)
    deleted = await filesystem_whitelist_store.remove(path)
    if not deleted:
        raise HTTPException(status_code=404, detail=f"Path '{path}' not in whitelist")
    return {"ok": True, "path": path}


@router.get("/filesystem-browser")
async def filesystem_browser(request: Request, path: str = "/"):
    _require_admin(request)
    from pathlib import Path
    p = Path(path)
    if not p.is_absolute():
        raise HTTPException(status_code=400, detail="path must be absolute")
    if not p.exists() or not p.is_dir():
        raise HTTPException(status_code=404, detail=f"Directory not found: {path}")

    entries = []
    try:
        for entry in sorted(p.iterdir()):
            try:
                if entry.is_dir() and not entry.is_symlink():
                    entries.append({"name": entry.name, "path": str(entry)})
            except PermissionError:
                continue
    except PermissionError:
        pass

    parent = str(p.parent) if p != p.parent else None
    return {"path": str(p), "parent": parent, "entries": entries[:300]}


# ── Models ────────────────────────────────────────────────────────────────────

@router.get("/models")
async def list_models(request: Request):
    from ..providers.models import get_available_models, get_access_tier
    from ..providers.registry import get_available_providers
    user = request.state.current_user
    user_id = user.id if user else None
    is_admin = user.is_admin if user else True
    models, default = await get_available_models(user_id=user_id, is_admin=is_admin)
    access = await get_access_tier(user_id=user_id, is_admin=is_admin)
    return {
        "models": models,
        "default": default,
        "providers": await get_available_providers(user_id=user_id),
        "access": access,
    }


@router.get("/models/info")
async def models_info(request: Request):
    from ..providers.models import get_models_info
    user = request.state.current_user
    user_id = user.id if user else None
    is_admin = user.is_admin if user else True
    return await get_models_info(user_id=user_id, is_admin=is_admin)


# ── Runtime limits ────────────────────────────────────────────────────────────

class LimitsIn(BaseModel):
    max_tool_calls: Optional[int] = None
    max_autonomous_runs_per_hour: Optional[int] = None
    max_concurrent_runs: Optional[int] = None


class ProxyTrustIn(BaseModel):
    trusted_ips: str


@router.get("/settings/limits")
async def get_limits(request: Request):
    _require_admin(request)
    async def _get(key: str, default: int) -> int:
        v = await credential_store.get(key)
        try:
            return int(v) if v else default
        except (ValueError, TypeError):
            return default

    mtc, mar, mcr = await asyncio_gather(
        _get("system:max_tool_calls", settings.max_tool_calls),
        _get("system:max_autonomous_runs_per_hour", settings.max_autonomous_runs_per_hour),
        _get("system:max_concurrent_runs", 3),
    )
    return {
        "max_tool_calls": mtc,
        "max_autonomous_runs_per_hour": mar,
        "max_concurrent_runs": mcr,
        "defaults": {
            "max_tool_calls": settings.max_tool_calls,
            "max_autonomous_runs_per_hour": settings.max_autonomous_runs_per_hour,
            "max_concurrent_runs": 3,
        },
    }


@router.post("/settings/limits")
async def set_limits(request: Request, body: LimitsIn):
    _require_admin(request)
    if body.max_tool_calls is not None:
        if body.max_tool_calls < 1:
            raise HTTPException(status_code=400, detail="max_tool_calls must be >= 1")
        await credential_store.set(
            "system:max_tool_calls", str(body.max_tool_calls),
            "Max tool calls per agent run",
        )
    if body.max_autonomous_runs_per_hour is not None:
        if body.max_autonomous_runs_per_hour < 1:
            raise HTTPException(status_code=400, detail="max_autonomous_runs_per_hour must be >= 1")
        await credential_store.set(
            "system:max_autonomous_runs_per_hour", str(body.max_autonomous_runs_per_hour),
            "Max autonomous scheduler runs per hour",
        )
    if body.max_concurrent_runs is not None:
        if body.max_concurrent_runs < 1:
            raise HTTPException(status_code=400, detail="max_concurrent_runs must be >= 1")
        await credential_store.set(
            "system:max_concurrent_runs", str(body.max_concurrent_runs),
            "Max concurrent agent runs",
        )
    return await get_limits()


@router.get("/queue")
async def get_queue_status(request: Request):
    """Return current run queue status."""
    _require_auth(request)
    return agent_runner.queue_status


# ── Browser trusted domains ───────────────────────────────────────────────────

class BrowserDomainIn(BaseModel):
    domain: str
    note: Optional[str] = None


@router.get("/my/browser-trusted")
async def list_browser_trusted(request: Request):
    from ..database import get_pool as _gp
    user = _require_auth(request)
    pool = await _gp()
    rows = await pool.fetch(
        "SELECT id, domain, note, created_at FROM browser_approved_domains "
        "WHERE owner_user_id = $1 ORDER BY domain",
        user.id,
    )
    return [dict(r) for r in rows]


@router.post("/my/browser-trusted")
async def add_browser_trusted(request: Request, body: BrowserDomainIn):
    from ..database import get_pool as _gp
    user = _require_auth(request)
    domain = body.domain.lower().strip().lstrip("*.")
    if not domain:
        raise HTTPException(status_code=400, detail="Invalid domain")
    pool = await _gp()
    try:
        await pool.execute(
            "INSERT INTO browser_approved_domains (owner_user_id, domain, note) "
            "VALUES ($1, $2, $3) ON CONFLICT (owner_user_id, domain) DO NOTHING",
            user.id, domain, body.note or None,
        )
    except Exception as e:
        raise HTTPException(status_code=400, detail=str(e))
    return {"ok": True, "domain": domain}


@router.delete("/my/browser-trusted/{domain:path}")
async def remove_browser_trusted(request: Request, domain: str):
    from ..database import get_pool as _gp
    user = _require_auth(request)
    domain = domain.lower().strip().lstrip("*.")
    pool = await _gp()
    await pool.execute(
        "DELETE FROM browser_approved_domains WHERE owner_user_id = $1 AND domain = $2",
        user.id, domain,
    )
    return {"ok": True}


@router.get("/settings/provider")
async def get_default_provider(request: Request):
    _require_admin(request)
    from ..providers.registry import get_available_providers
    from ..config import settings as _settings
    val = await credential_store.get("system:default_provider")
    available = await get_available_providers()
    current = val or _settings.default_provider
    # If the saved provider no longer has a key, fall back to the first available
    if current not in available and available:
        current = available[0]
    return {"default_provider": current, "available_providers": available}


class ProviderIn(BaseModel):
    default_provider: str


@router.post("/settings/provider")
async def set_default_provider(request: Request, body: ProviderIn):
    _require_admin(request)
    if body.default_provider not in {"anthropic", "openrouter", "openai"}:
        raise HTTPException(status_code=400, detail="Invalid provider. Use: anthropic, openrouter, openai")
    await credential_store.set("system:default_provider", body.default_provider, "Default AI provider")
    return {"default_provider": body.default_provider}


@router.get("/settings/default-models")
async def get_default_models(request: Request):
    _require_admin(request)
    return {
        "default_model":      await credential_store.get("system:default_chat_model") or "",
        "free_tier_model":    await credential_store.get("system:default_chat_model_free") or "",
    }


class DefaultModelsIn(BaseModel):
    default_model: Optional[str] = None
    free_tier_model: Optional[str] = None


@router.post("/settings/default-models")
async def set_default_models(request: Request, body: DefaultModelsIn):
    _require_admin(request)
    if body.default_model is not None:
        if body.default_model.strip():
            await credential_store.set(
                "system:default_chat_model", body.default_model.strip(),
                "Default chat model for all users",
            )
        else:
            await credential_store.delete("system:default_chat_model")
    if body.free_tier_model is not None:
        if body.free_tier_model.strip():
            await credential_store.set(
                "system:default_chat_model_free", body.free_tier_model.strip(),
                "Default chat model for free-tier users (no own API key)",
            )
        else:
            await credential_store.delete("system:default_chat_model_free")
    return await get_default_models()


@router.get("/settings/proxy-trust")
async def get_proxy_trust(request: Request):
    _require_admin(request)
    return {
        "trusted_ips": await credential_store.get("system:trusted_proxy_ips") or "127.0.0.1",
    }


@router.post("/settings/proxy-trust")
async def set_proxy_trust(request: Request, body: ProxyTrustIn):
    _require_admin(request)
    await credential_store.set(
        "system:trusted_proxy_ips", body.trusted_ips.strip(),
        "Trusted reverse proxy IPs for X-Forwarded-For headers",
    )
    return {"trusted_ips": body.trusted_ips.strip()}


class UserBaseFolderIn(BaseModel):
    path: str = ""


@router.get("/settings/users-base-folder")
async def get_users_base_folder(request: Request):
    _require_admin(request)
    return {"path": await credential_store.get("system:users_base_folder") or ""}


@router.post("/settings/users-base-folder")
async def set_users_base_folder(request: Request, body: UserBaseFolderIn):
    _require_admin(request)
    path = body.path.strip()
    if path:
        from pathlib import Path as _Path
        p = _Path(path)
        if not p.is_absolute():
            raise HTTPException(status_code=400, detail="Path must be absolute.")
        if not p.exists():
            raise HTTPException(status_code=400, detail="Path does not exist on the server.")
        await credential_store.set("system:users_base_folder", path, "Base folder for per-user subfolders")
        # Provision folders for all existing users
        from ..users import list_users, _provision_user_folder
        for u in await list_users():
            await _provision_user_folder(u["username"])
    else:
        await credential_store.delete("system:users_base_folder")
    return {"path": path}


# ── Admin CalDAV / CardDAV settings ──────────────────────────────────────────

@router.get("/settings/caldav")
async def get_admin_caldav(request: Request):
    _require_admin(request)
    get = credential_store.get
    return {
        "host":               await get("mailcow_host") or "",
        "username":           await get("mailcow_username") or "",
        "password_set":       bool(await get("mailcow_password")),
        "calendar_name":      await get("caldav_calendar_name") or "",
        "contacts_allow_write": (await get("contacts:allow_write")) == "1",
        "carddav_same_as_caldav": (await get("carddav_same_as_caldav")) == "1",
        "carddav_url":        await get("carddav_url") or "",
        "carddav_username":   await get("carddav_username") or "",
        "carddav_password_set": bool(await get("carddav_password")),
        "imap_host":          await get("mailcow_imap_host") or "",
        "smtp_host":          await get("mailcow_smtp_host") or "",
        "smtp_port":          await get("mailcow_smtp_port") or "",
    }


@router.post("/settings/caldav")
async def set_admin_caldav(request: Request):
    _require_admin(request)
    body = await request.json()

    async def _set(key, val, desc=""):
        val = (val or "").strip()
        if val:
            await credential_store.set(key, val, desc)
        else:
            await credential_store.delete(key)

    async def _set_bool(key, val):
        if val:
            await credential_store.set(key, "1")
        else:
            await credential_store.delete(key)

    await _set("mailcow_host",         body.get("host"),          "Mailcow hostname")
    await _set("mailcow_username",      body.get("username"),      "Mailcow username")
    # Only update password if a new value is provided
    pwd = (body.get("password") or "").strip()
    if pwd:
        await credential_store.set("mailcow_password", pwd, "Mailcow password")
    await _set("caldav_calendar_name",  body.get("calendar_name"), "Default calendar name")
    await _set_bool("contacts:allow_write", body.get("contacts_allow_write"))

    same = bool(body.get("carddav_same_as_caldav"))
    await _set_bool("carddav_same_as_caldav", same)
    if same:
        for k in ("carddav_url", "carddav_username", "carddav_password"):
            await credential_store.delete(k)
    else:
        await _set("carddav_url",      body.get("carddav_url"),      "CardDAV server URL")
        await _set("carddav_username", body.get("carddav_username"),  "CardDAV username")
        cpwd = (body.get("carddav_password") or "").strip()
        if cpwd:
            await credential_store.set("carddav_password", cpwd, "CardDAV password")

    # Email tool overrides (optional host overrides for IMAP/SMTP)
    await _set("mailcow_imap_host", body.get("imap_host"), "IMAP host override")
    await _set("mailcow_smtp_host", body.get("smtp_host"), "SMTP host override")
    await _set("mailcow_smtp_port", body.get("smtp_port"), "SMTP port override")

    return {"ok": True}


# ── Admin Pushover settings ───────────────────────────────────────────────────

@router.get("/settings/pushover")
async def get_admin_pushover(request: Request):
    _require_admin(request)
    return {
        "app_token_set": bool(await credential_store.get("pushover_app_token")),
        "user_key_set":  bool(await credential_store.get("pushover_user_key")),
    }


@router.post("/settings/pushover")
async def set_admin_pushover(request: Request):
    _require_admin(request)
    body = await request.json()
    for field, key in [("app_token", "pushover_app_token"), ("user_key", "pushover_user_key")]:
        val = (body.get(field) or "").strip()
        if val:
            await credential_store.set(key, val)
        # Never clear on empty — must explicitly use delete endpoint
    return {"ok": True}


# ── User Pushover settings ────────────────────────────────────────────────────

@router.get("/my/pushover")
async def get_my_pushover(request: Request):
    _require_auth(request)
    user = request.state.current_user
    app_ok = bool(await credential_store.get("pushover_app_token"))
    user_key = await _user_settings_store.get(user["id"], "pushover_user_key")
    return {"app_token_configured": app_ok, "user_key_set": bool(user_key)}


@router.post("/my/pushover")
async def set_my_pushover(request: Request):
    _require_auth(request)
    user = request.state.current_user
    body = await request.json()
    key = (body.get("user_key") or "").strip()
    if key:
        await _user_settings_store.set(user["id"], "pushover_user_key", key)
    return {"ok": True}


@router.delete("/my/pushover")
async def delete_my_pushover(request: Request):
    _require_auth(request)
    user = request.state.current_user
    await _user_settings_store.delete(user["id"], "pushover_user_key")
    return {"ok": True}


# ── Agents ────────────────────────────────────────────────────────────────────

class AgentIn(BaseModel):
    name: str
    prompt: str
    model: str
    description: str = ""
    can_create_subagents: bool = False
    allowed_tools: Optional[list[str]] = None
    schedule: Optional[str] = None
    enabled: bool = True
    max_tool_calls: Optional[int] = None
    prompt_mode: str = "combined"


class AgentUpdate(BaseModel):
    name: Optional[str] = None
    prompt: Optional[str] = None
    model: Optional[str] = None
    description: Optional[str] = None
    can_create_subagents: Optional[bool] = None
    allowed_tools: Optional[list[str]] = None
    schedule: Optional[str] = None
    enabled: Optional[bool] = None
    max_tool_calls: Optional[int] = None
    prompt_mode: Optional[str] = None


@router.get("/agents")
async def list_agents(request: Request):
    user = _require_auth(request)
    owner_filter = None if user.is_admin else user.id
    agents = await agent_store.list_agents(owner_user_id=owner_filter)
    # Exclude email handling agents — they are managed via Email Accounts settings
    from ..database import get_pool as _gp
    pool = await _gp()
    handler_ids = {str(r["agent_id"]) for r in await pool.fetch(
        "SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL"
    )}
    return [a for a in agents if a["id"] not in handler_ids]


@router.post("/agents", status_code=201)
async def create_agent(request: Request, body: AgentIn):
    user = _require_auth(request)
    # Stamp owner; synthetic API-key admin gets NULL (no real user row)
    from ..auth import SYNTHETIC_API_ADMIN
    owner_user_id = None if user.id == SYNTHETIC_API_ADMIN.id else user.id
    agent = await agent_store.create_agent(
        name=body.name,
        prompt=body.prompt,
        model=body.model,
        description=body.description,
        can_create_subagents=body.can_create_subagents,
        allowed_tools=body.allowed_tools or [],
        schedule=body.schedule,
        enabled=body.enabled,
        max_tool_calls=body.max_tool_calls,
        prompt_mode=body.prompt_mode,
        owner_user_id=owner_user_id,
    )
    agent_runner.reschedule(agent)
    return agent


@router.get("/agents/{agent_id}")
async def get_agent(request: Request, agent_id: str):
    user = _require_auth(request)
    agent = await agent_store.get_agent(agent_id)
    return _check_agent_access(agent, user)


@router.put("/agents/{agent_id}")
async def update_agent(request: Request, agent_id: str, body: AgentUpdate):
    user = _require_auth(request)
    _check_agent_access(await agent_store.get_agent(agent_id), user)
    fields = {k: v for k, v in body.model_dump().items()
              if v is not None or k == "max_tool_calls"}
    agent = await agent_store.update_agent(agent_id, **fields)
    if not agent:
        raise HTTPException(status_code=404, detail="Agent not found")
    agent_runner.reschedule(agent)
    return agent


@router.delete("/agents/{agent_id}")
async def delete_agent(request: Request, agent_id: str):
    user = _require_auth(request)
    _check_agent_access(await agent_store.get_agent(agent_id), user)
    deleted = await agent_store.delete_agent(agent_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Agent not found")
    agent_runner.remove(agent_id)
    return {"ok": True}


@router.post("/agents/{agent_id}/toggle")
async def toggle_agent(request: Request, agent_id: str):
    user = _require_auth(request)
    agent = _check_agent_access(await agent_store.get_agent(agent_id), user)
    updated = await agent_store.update_agent(agent_id, enabled=not agent["enabled"])
    agent_runner.reschedule(updated)
    return updated


@router.post("/agents/{agent_id}/run")
async def run_agent_now(request: Request, agent_id: str):
    user = _require_auth(request)
    _check_agent_access(await agent_store.get_agent(agent_id), user)
    run = await agent_runner.run_agent_now(agent_id)
    return {"ok": True, "run": run}


@router.post("/agents/{agent_id}/stop")
async def stop_agent(request: Request, agent_id: str):
    user = _require_auth(request)
    _check_agent_access(await agent_store.get_agent(agent_id), user)
    run_id = await agent_runner.find_active_run(agent_id)
    if not run_id:
        raise HTTPException(status_code=404, detail="No active run found for this agent")
    stopped = agent_runner.stop_run(run_id)
    return {"ok": stopped, "run_id": run_id}


@router.get("/agents/{agent_id}/runs")
async def get_agent_runs(request: Request, agent_id: str):
    user = _require_auth(request)
    _check_agent_access(await agent_store.get_agent(agent_id), user)
    return await agent_store.list_runs(agent_id=agent_id)


# ── Agent runs (cross-agent) ──────────────────────────────────────────────────

@router.get("/agent-runs")
async def list_all_runs(
    request: Request,
    since: str = "7d",
    start: str = "",
    end: str = "",
    status: str = "",
):
    user = _require_auth(request)
    now = datetime.now(timezone.utc)
    since_dt: str | None = None

    if start:
        since_dt = start
    elif since == "today":
        since_dt = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
    elif since == "7d":
        since_dt = (now - timedelta(days=7)).isoformat()
    elif since == "30d":
        since_dt = (now - timedelta(days=30)).isoformat()

    runs = await agent_store.list_runs(since=since_dt, status=status or None)

    # Exclude email handler agents (managed via Email Accounts, not the Agents page)
    from ..database import get_pool as _gp
    _pool = await _gp()
    handler_ids = {str(r["agent_id"]) for r in await _pool.fetch(
        "SELECT agent_id FROM email_accounts WHERE agent_id IS NOT NULL"
    )}
    runs = [r for r in runs if r["agent_id"] not in handler_ids]

    # Non-admins see only runs for their own agents
    if not user.is_admin:
        own_agents = await agent_store.list_agents(owner_user_id=user.id)
        own_ids = {a["id"] for a in own_agents}
        runs = [r for r in runs if r["agent_id"] in own_ids]

    agents_cache: dict[str, dict] = {}
    for run in runs:
        aid = run["agent_id"]
        if aid not in agents_cache:
            a = await agent_store.get_agent(aid)
            agents_cache[aid] = a or {}
        run["agent_name"] = agents_cache[aid].get("name", "—")

    return runs


@router.get("/agent-runs/{run_id}")
async def get_agent_run(request: Request, run_id: str):
    user = _require_auth(request)
    from ..agents.tasks import get_run
    run = await get_run(run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Run not found")
    _check_agent_access(await agent_store.get_agent(run["agent_id"]), user)
    return run


@router.post("/agent-runs/{run_id}/stop")
async def stop_run(request: Request, run_id: str):
    _require_admin(request)
    stopped = agent_runner.stop_run(run_id)
    if not stopped:
        raise HTTPException(status_code=404, detail="Run not found or already stopped")
    return {"ok": True, "run_id": run_id}


# ── Usage / cost overview ─────────────────────────────────────────────────────

@router.get("/usage")
async def get_usage(
    request: Request,
    since: str = "7d",
    start: str = "",
    end: str = "",
):
    """Aggregate token and cost usage by agent for the usage overview page."""
    user = _require_auth(request)
    if not user.is_admin:
        from ..database import user_settings_store as _uss
        if await _uss.get(user.id, "use_admin_keys"):
            raise HTTPException(status_code=403, detail="Not available on admin API keys")
        has_own = (
            await _uss.get(user.id, "anthropic_api_key") or
            await _uss.get(user.id, "openrouter_api_key") or
            await _uss.get(user.id, "openai_api_key")
        )
        if not has_own:
            raise HTTPException(status_code=403, detail="Not available on admin API keys")
    from ..database import get_pool as _gp
    pool = await _gp()

    now = datetime.now(timezone.utc)
    since_dt: str | None = None

    if start:
        since_dt = start
    elif since == "today":
        since_dt = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
    elif since == "7d":
        since_dt = (now - timedelta(days=7)).isoformat()
    elif since == "30d":
        since_dt = (now - timedelta(days=30)).isoformat()
    # since == "all" → no date filter

    # Build WHERE clauses
    clauses: list[str] = ["ar.status IN ('success', 'error', 'stopped')"]
    params: list = []
    n = 1

    if since_dt:
        clauses.append(f"ar.started_at >= ${n}"); params.append(since_dt); n += 1
    if end:
        clauses.append(f"ar.started_at <= ${n}"); params.append(end); n += 1

    # Non-admin sees only their own agents
    if not user.is_admin:
        own_agents = await agent_store.list_agents(owner_user_id=user.id)
        own_ids = [a["id"] for a in own_agents]
        if own_ids:
            placeholders = ", ".join(f"${n + i}" for i in range(len(own_ids)))
            clauses.append(f"ar.agent_id IN ({placeholders})")
            params.extend(own_ids)
            n += len(own_ids)
        else:
            # No agents → empty result
            return {"summary": {"runs": 0, "input_tokens": 0, "output_tokens": 0, "cost_usd": None}, "by_agent": []}

    where = "WHERE " + " AND ".join(clauses)

    rows = await pool.fetch(
        f"""
        SELECT
            ar.agent_id,
            a.name        AS agent_name,
            a.model       AS agent_model,
            COUNT(*)      AS runs,
            SUM(ar.input_tokens)  AS input_tokens,
            SUM(ar.output_tokens) AS output_tokens,
            SUM(ar.cost_usd)      AS cost_usd
        FROM agent_runs ar
        LEFT JOIN agents a ON a.id = ar.agent_id
        {where}
        GROUP BY ar.agent_id, a.name, a.model
        ORDER BY cost_usd DESC NULLS LAST, (SUM(ar.input_tokens) + SUM(ar.output_tokens)) DESC
        """,
        *params,
    )

    by_agent = []
    total_input = 0
    total_output = 0
    total_cost: float | None = None
    total_runs = 0

    for row in rows:
        inp = int(row["input_tokens"] or 0)
        out = int(row["output_tokens"] or 0)
        cost = float(row["cost_usd"]) if row["cost_usd"] is not None else None
        runs = int(row["runs"])
        total_input += inp
        total_output += out
        total_runs += runs
        if cost is not None:
            total_cost = (total_cost or 0.0) + cost
        by_agent.append({
            "agent_id": str(row["agent_id"]),
            "agent_name": row["agent_name"] or "—",
            "model": row["agent_model"] or "",
            "runs": runs,
            "input_tokens": inp,
            "output_tokens": out,
            "cost_usd": cost,
        })

    # ── Chat session usage ────────────────────────────────────────────────────
    chat_clauses: list[str] = ["task_id IS NULL"]  # chat only, not agent/task runs
    chat_params: list = []
    cn = 1

    if since_dt:
        chat_clauses.append(f"started_at >= ${cn}"); chat_params.append(since_dt); cn += 1
    if end:
        chat_clauses.append(f"started_at <= ${cn}"); chat_params.append(end); cn += 1
    if not user.is_admin:
        chat_clauses.append(f"user_id = ${cn}"); chat_params.append(user.id); cn += 1

    chat_where = "WHERE " + " AND ".join(chat_clauses)
    chat_row = await pool.fetchrow(
        f"""
        SELECT
            COUNT(*)             AS sessions,
            SUM(input_tokens)    AS input_tokens,
            SUM(output_tokens)   AS output_tokens,
            SUM(cost_usd)        AS cost_usd
        FROM conversations
        {chat_where}
        """,
        *chat_params,
    )

    chat_inp  = int(chat_row["input_tokens"]  or 0)
    chat_out  = int(chat_row["output_tokens"] or 0)
    chat_cost = float(chat_row["cost_usd"]) if chat_row["cost_usd"] is not None else None
    chat_sessions = int(chat_row["sessions"] or 0)

    total_input  += chat_inp
    total_output += chat_out
    total_runs   += chat_sessions
    if chat_cost is not None:
        total_cost = (total_cost or 0.0) + chat_cost

    return {
        "summary": {
            "runs": total_runs,
            "input_tokens": total_input,
            "output_tokens": total_output,
            "cost_usd": total_cost,
        },
        "by_agent": by_agent,
        "chat": {
            "sessions": chat_sessions,
            "input_tokens": chat_inp,
            "output_tokens": chat_out,
            "cost_usd": chat_cost,
        },
    }


# ── Usage: clear cost data ────────────────────────────────────────────────────

@router.delete("/usage/cost")
async def clear_cost_data(request: Request):
    """Delete all agent_runs and null out conversation cost_usd. Admin only."""
    _require_admin(request)
    from ..database import get_pool as _gp
    pool = await _gp()
    async with pool.acquire() as conn:
        await conn.execute("DELETE FROM agent_runs")
        await conn.execute("UPDATE conversations SET cost_usd = NULL WHERE cost_usd IS NOT NULL")
    return {"ok": True}


# ── Inbox triggers ────────────────────────────────────────────────────────────

class InboxTriggerIn(BaseModel):
    trigger_word: str
    agent_id: str
    description: str = ""
    enabled: bool = True


@router.get("/inbox-triggers")
async def list_inbox_triggers(request: Request):
    _require_admin(request)
    from ..inbox.triggers import list_triggers
    return await list_triggers()


@router.post("/inbox-triggers", status_code=201)
async def create_inbox_trigger(request: Request, body: InboxTriggerIn):
    _require_admin(request)
    from ..inbox.triggers import create_trigger
    return await create_trigger(body.trigger_word, body.agent_id, body.description, body.enabled)


@router.put("/inbox-triggers/{id}")
async def update_inbox_trigger(request: Request, id: str, body: InboxTriggerIn):
    _require_admin(request)
    from ..inbox.triggers import update_trigger
    ok = await update_trigger(
        id,
        trigger_word=body.trigger_word,
        agent_id=body.agent_id,
        description=body.description,
        enabled=body.enabled,
    )
    if not ok:
        raise HTTPException(status_code=404, detail="Trigger not found")
    return {"ok": True}


@router.delete("/inbox-triggers/{id}")
async def delete_inbox_trigger(request: Request, id: str):
    _require_admin(request)
    from ..inbox.triggers import delete_trigger
    if not await delete_trigger(id):
        raise HTTPException(status_code=404, detail="Trigger not found")
    return {"ok": True}


@router.post("/inbox-triggers/{id}/toggle")
async def toggle_inbox_trigger(request: Request, id: str):
    _require_admin(request)
    from ..inbox.triggers import toggle_trigger
    await toggle_trigger(id)
    return {"ok": True}


@router.get("/inbox/status")
async def inbox_status(request: Request):
    _require_admin(request)
    from ..inbox.listener import inbox_listener
    return {
        "global": inbox_listener.status,
        "all": inbox_listener.all_statuses(),
    }


@router.post("/inbox/reconnect")
async def inbox_reconnect(request: Request):
    _require_admin(request)
    from ..inbox.listener import inbox_listener
    inbox_listener.reconnect()
    return {"ok": True}


@router.post("/inbox/disconnect")
async def inbox_disconnect(request: Request):
    _require_admin(request)
    from ..inbox.listener import inbox_listener
    inbox_listener.stop()
    return {"ok": True}


# ── Telegram ──────────────────────────────────────────────────────────────────

@router.get("/telegram/status")
async def telegram_status(request: Request):
    _require_admin(request)
    from ..telegram.listener import telegram_listener
    return telegram_listener.status


@router.post("/telegram/reconnect")
async def telegram_reconnect(request: Request):
    _require_admin(request)
    from ..telegram.listener import telegram_listener
    telegram_listener.reconnect()
    return {"ok": True}


@router.post("/telegram/disconnect")
async def telegram_disconnect(request: Request):
    _require_admin(request)
    from ..telegram.listener import telegram_listener
    telegram_listener.stop()
    return {"ok": True}


@router.get("/telegram-whitelist")
async def list_telegram_whitelist(request: Request):
    _require_admin(request)
    from ..telegram.triggers import list_whitelist
    return await list_whitelist()


@router.post("/telegram-whitelist")
async def add_telegram_whitelist(request: Request, body: dict):
    _require_admin(request)
    from ..telegram.triggers import add_to_whitelist
    chat_id = body.get("chat_id", "").strip()
    if not chat_id:
        raise HTTPException(status_code=400, detail="chat_id required")
    return await add_to_whitelist(chat_id, body.get("label", ""))


@router.delete("/telegram-whitelist/{chat_id}")
async def remove_telegram_whitelist(request: Request, chat_id: str):
    _require_admin(request)
    from ..telegram.triggers import remove_from_whitelist
    if not await remove_from_whitelist(chat_id):
        raise HTTPException(status_code=404, detail="chat_id not found")
    return {"ok": True}


@router.get("/telegram-triggers")
async def list_telegram_triggers(request: Request):
    _require_admin(request)
    from ..telegram.triggers import list_triggers
    return await list_triggers()


@router.post("/telegram-triggers")
async def create_telegram_trigger(request: Request, body: dict):
    _require_admin(request)
    from ..telegram.triggers import create_trigger
    trigger_word = body.get("trigger_word", "").strip()
    agent_id = body.get("agent_id", "").strip()
    if not trigger_word or not agent_id:
        raise HTTPException(status_code=400, detail="trigger_word and agent_id required")
    return await create_trigger(
        trigger_word=trigger_word,
        agent_id=agent_id,
        description=body.get("description", ""),
        enabled=body.get("enabled", True),
    )


@router.put("/telegram-triggers/{id}")
async def update_telegram_trigger(request: Request, id: str, body: dict):
    _require_admin(request)
    from ..telegram.triggers import update_trigger
    _ALLOWED = {"trigger_word", "agent_id", "description", "enabled"}
    safe_body = {k: v for k, v in body.items() if k in _ALLOWED}
    if not await update_trigger(id, **safe_body):
        raise HTTPException(status_code=404, detail="Trigger not found")
    return {"ok": True}


@router.delete("/telegram-triggers/{id}")
async def delete_telegram_trigger(request: Request, id: str):
    _require_admin(request)
    from ..telegram.triggers import delete_trigger
    if not await delete_trigger(id):
        raise HTTPException(status_code=404, detail="Trigger not found")
    return {"ok": True}


@router.post("/telegram-triggers/{id}/toggle")
async def toggle_telegram_trigger(request: Request, id: str):
    _require_admin(request)
    from ..telegram.triggers import toggle_trigger
    await toggle_trigger(id)
    return {"ok": True}


@router.get("/telegram/default-agent")
async def get_telegram_default_agent(request: Request):
    _require_admin(request)
    agent_id = await credential_store.get("telegram:default_agent_id") or ""
    return {"agent_id": agent_id}


@router.post("/telegram/default-agent")
async def set_telegram_default_agent(request: Request, body: dict):
    _require_admin(request)
    agent_id = body.get("agent_id", "").strip()
    if agent_id:
        await credential_store.set("telegram:default_agent_id", agent_id,
                                   description="Default Telegram agent (no trigger match)")
    else:
        await credential_store.delete("telegram:default_agent_id")
    return {"ok": True}


# ── 2nd Brain ─────────────────────────────────────────────────────────────────

class CaptureIn(BaseModel):
    content: str


@router.get("/brain/status")
async def get_brain_status(request: Request):
    _require_admin(request)
    from ..brain.database import get_pool, get_stats, browse_thoughts
    if get_pool() is None:
        return {"connected": False}
    try:
        stats = await get_stats()
        recent = await browse_thoughts(limit=10)
        return {"connected": True, "stats": stats, "recent": recent}
    except Exception as e:
        return {"connected": False, "error": str(e)}


@router.get("/brain/search")
async def search_brain(request: Request, q: str = "", limit: int = 20):
    _require_admin(request)
    from ..brain.database import get_pool
    from ..brain.search import semantic_search
    if get_pool() is None:
        raise HTTPException(status_code=503, detail="Brain DB not available")
    if not q.strip():
        raise HTTPException(status_code=400, detail="Query required")
    results = await semantic_search(q.strip(), threshold=0.3, limit=limit)
    return {"results": results}


@router.post("/brain/capture")
async def capture_thought(request: Request, body: CaptureIn):
    _require_admin(request)
    from ..brain.database import get_pool
    from ..brain.ingest import ingest_thought
    if get_pool() is None:
        raise HTTPException(status_code=503, detail="Brain DB not available")
    result = await ingest_thought(body.content)
    return {"ok": True, "id": result["id"], "confirmation": result["confirmation"]}


# ── System prompt files (SOUL.md / USER.md) ───────────────────────────────────

_PROMPT_FILES = {
    "soul": "SOUL.md",
    "user": "USER.md",
}


def _prompt_file_path(key: str):
    from pathlib import Path
    filename = _PROMPT_FILES.get(key)
    if not filename:
        raise HTTPException(status_code=404, detail="Unknown prompt file")
    return Path(__file__).parent.parent.parent / filename


@router.get("/system-prompt/{key}")
async def get_system_prompt(request: Request, key: str):
    _require_admin(request)
    path = _prompt_file_path(key)
    content = path.read_text(encoding="utf-8") if path.exists() else ""
    return {"content": content}


@router.post("/system-prompt/{key}")
async def save_system_prompt(request: Request, key: str, body: dict):
    _require_admin(request)
    path = _prompt_file_path(key)
    content = body.get("content", "")
    path.write_text(content, encoding="utf-8")
    return {"ok": True}


# ── Tool list ──────────────────────────────────────────────────────────────────

@router.get("/tools")
async def list_tools(request: Request):
    user = _require_auth(request)
    from ..main import _registry
    if _registry is None:
        return []
    tools = _registry.all_tools()
    result = []
    for t in tools:
        if t.name == "bash" and not user.is_admin:
            continue
        is_mcp = t.name.startswith("mcp__")
        # Non-admins don't see global MCP tools — they get their own below
        if is_mcp and not user.is_admin:
            continue
        result.append({
            "name": t.name,
            "is_mcp": is_mcp,
            "server": t.name.split("__")[1] if is_mcp else None,
            "server_display_name": getattr(t, "_server_display_name", None),
        })
    # Inject per-user MCP tools for non-admin users
    if not user.is_admin:
        from ..mcp_client.manager import discover_user_mcp_tools
        user_mcp_tools = await discover_user_mcp_tools(user.id)
        for t in user_mcp_tools:
            result.append({
                "name": t.name,
                "is_mcp": True,
                "server": t.name.split("__")[1] if "__" in t.name else None,
                "server_display_name": getattr(t, "_server_display_name", getattr(t, "server_name", None)),
            })
    return result


# ── MCP servers ────────────────────────────────────────────────────────────────

class McpServerIn(BaseModel):
    name: str
    url: str
    transport: str = "sse"
    api_key: str = ""
    headers: Optional[dict] = None
    enabled: bool = True


class McpServerUpdate(BaseModel):
    name: Optional[str] = None
    url: Optional[str] = None
    transport: Optional[str] = None
    api_key: Optional[str] = None
    headers: Optional[dict] = None
    enabled: Optional[bool] = None


@router.get("/mcp-servers")
async def list_mcp_servers(request: Request):
    _require_admin(request)
    from ..mcp_client.store import list_servers
    return await list_servers(include_secrets=False, user_id="GLOBAL")


@router.post("/mcp-servers", status_code=201)
async def create_mcp_server(request: Request, body: McpServerIn):
    _require_admin(request)
    from ..mcp_client.store import list_servers, create_server
    from ..mcp_client.manager import reload_server_tools
    from ..main import _registry
    existing = await list_servers(user_id="GLOBAL")
    if any(s["name"] == body.name for s in existing):
        raise HTTPException(status_code=400, detail="A server with that name already exists")
    server = await create_server(
        name=body.name, url=body.url, transport=body.transport,
        api_key=body.api_key, headers=body.headers, enabled=body.enabled,
    )
    if _registry:
        reload_server_tools(_registry)
    return server


@router.get("/mcp-servers/{server_id}")
async def get_mcp_server(request: Request, server_id: str):
    _require_admin(request)
    from ..mcp_client.store import get_server
    server = await get_server(server_id, include_secrets=True)
    if not server:
        raise HTTPException(status_code=404, detail="MCP server not found")
    return server


@router.put("/mcp-servers/{server_id}")
async def update_mcp_server(request: Request, server_id: str, body: McpServerUpdate):
    _require_admin(request)
    from ..mcp_client.store import update_server
    from ..mcp_client.manager import reload_server_tools
    from ..main import _registry
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    server = await update_server(server_id, **fields)
    if not server:
        raise HTTPException(status_code=404, detail="MCP server not found")
    if _registry:
        reload_server_tools(_registry)
    return server


@router.delete("/mcp-servers/{server_id}")
async def delete_mcp_server(request: Request, server_id: str):
    _require_admin(request)
    from ..mcp_client.store import delete_server
    from ..mcp_client.manager import reload_server_tools
    from ..main import _registry
    deleted = await delete_server(server_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="MCP server not found")
    if _registry:
        reload_server_tools(_registry)
    return {"ok": True}


@router.post("/mcp-servers/{server_id}/toggle")
async def toggle_mcp_server(request: Request, server_id: str):
    _require_admin(request)
    from ..mcp_client.store import get_server, update_server
    from ..mcp_client.manager import reload_server_tools
    from ..main import _registry
    server = await get_server(server_id)
    if not server:
        raise HTTPException(status_code=404, detail="MCP server not found")
    updated = await update_server(server_id, enabled=not server["enabled"])
    if _registry:
        reload_server_tools(_registry)
    return updated


@router.post("/mcp-servers/{server_id}/refresh")
async def refresh_mcp_server(request: Request, server_id: str):
    _require_admin(request)
    from ..mcp_client.store import get_server
    from ..mcp_client.manager import discover_tools, reload_server_tools
    from ..main import _registry
    server = await get_server(server_id, include_secrets=True)
    if not server:
        raise HTTPException(status_code=404, detail="MCP server not found")
    tools = await discover_tools(server)
    if _registry:
        reload_server_tools(_registry)
    return {"tool_count": len(tools), "tools": [t["tool_name"] for t in tools]}


# ── Security settings ─────────────────────────────────────────────────────────

_UPLOAD_DEFAULT_EXTENSIONS: list[str] = [
    # Text / code
    "txt", "md", "csv", "json", "xml", "yaml", "yml", "toml", "ini", "conf", "cfg",
    "html", "css", "js", "ts", "jsx", "tsx", "py", "sh", "bash", "log", "sql",
    "env", "rst", "diff", "patch", "tsv", "nfo", "gitignore", "dockerfile",
    "rb", "go", "java", "c", "cpp", "h", "rs", "swift", "kt",
    # Images
    "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "tiff", "avif", "heic", "heif",
    # Documents
    "pdf",
]

# Exact filenames (no extension) that are always allowed regardless of extension rules.
# Checked by lowercase basename — not editable via the policy UI.
_UPLOAD_ALLOWED_EXACT_NAMES: frozenset[str] = frozenset([
    "known_hosts", "authorized_keys", "config",  # SSH
    "makefile", "procfile", "dockerfile",         # build
    ".env", ".gitignore", ".htaccess",            # config dotfiles
])
_UPLOAD_DEFAULT_MAX_MB = 50
_UPLOAD_DEFAULT_MAX_FILES = 20


async def _get_upload_policy() -> dict:
    import json as _json
    raw_ext = await credential_store.get("system:upload_allowed_extensions")
    raw_mb  = await credential_store.get("system:upload_max_file_size_mb")
    raw_n   = await credential_store.get("system:upload_max_files")
    try:
        exts = _json.loads(raw_ext) if raw_ext else _UPLOAD_DEFAULT_EXTENSIONS
    except Exception:
        exts = _UPLOAD_DEFAULT_EXTENSIONS
    try:
        max_mb = int(raw_mb) if raw_mb else _UPLOAD_DEFAULT_MAX_MB
    except (ValueError, TypeError):
        max_mb = _UPLOAD_DEFAULT_MAX_MB
    try:
        max_files = int(raw_n) if raw_n else _UPLOAD_DEFAULT_MAX_FILES
    except (ValueError, TypeError):
        max_files = _UPLOAD_DEFAULT_MAX_FILES
    return {"allowed_extensions": exts, "max_file_size_mb": max_mb, "max_files": max_files}


class SecuritySettingsIn(BaseModel):
    sanitize_enhanced: Optional[bool] = None
    canary_enabled: Optional[bool] = None
    output_validation_enabled: Optional[bool] = None
    llm_screen_enabled: Optional[bool] = None
    llm_screen_model: Optional[str] = None
    llm_screen_block: Optional[bool] = None
    truncation_enabled: Optional[bool] = None
    max_web_chars: Optional[int] = None
    max_email_chars: Optional[int] = None
    max_file_chars: Optional[int] = None
    max_subject_chars: Optional[int] = None
    upload_allowed_extensions: Optional[list[str]] = None
    upload_max_file_size_mb: Optional[int] = None
    upload_max_files: Optional[int] = None


@router.get("/settings/security")
async def get_security_settings(request: Request):
    _require_admin(request)
    async def _bool(key: str) -> bool:
        return await credential_store.get(key) == "1"

    async def _int(key: str, default: int) -> int:
        raw = await credential_store.get(key)
        try:
            return int(raw) if raw else default
        except (ValueError, TypeError):
            return default

    (
        sanitize_enhanced, canary_enabled, output_validation_enabled,
        truncation_enabled, llm_screen_enabled, llm_screen_block,
        max_web_chars, max_email_chars, max_file_chars, max_subject_chars,
        llm_screen_model, upload_policy,
    ) = await asyncio_gather(
        _bool("system:security_sanitize_enhanced"),
        _bool("system:security_canary_enabled"),
        _bool("system:security_output_validation_enabled"),
        _bool("system:security_truncation_enabled"),
        _bool("system:security_llm_screen_enabled"),
        _bool("system:security_llm_screen_block"),
        _int("system:security_max_web_chars", 20000),
        _int("system:security_max_email_chars", 6000),
        _int("system:security_max_file_chars", 20000),
        _int("system:security_max_subject_chars", 200),
        credential_store.get("system:security_llm_screen_model"),
        _get_upload_policy(),
    )
    return {
        "sanitize_enhanced": sanitize_enhanced,
        "canary_enabled": canary_enabled,
        "output_validation_enabled": output_validation_enabled,
        "truncation_enabled": truncation_enabled,
        "max_web_chars": max_web_chars,
        "max_email_chars": max_email_chars,
        "max_file_chars": max_file_chars,
        "max_subject_chars": max_subject_chars,
        "llm_screen_enabled": llm_screen_enabled,
        "llm_screen_model": llm_screen_model or "google/gemini-flash-1.5",
        "llm_screen_block": llm_screen_block,
        **upload_policy,
    }


@router.post("/settings/security")
async def save_security_settings(request: Request, body: SecuritySettingsIn):
    _require_admin(request)
    from ..security_screening import _invalidate_toggle_cache, _limit_cache

    ops = []

    if body.sanitize_enhanced is not None:
        ops.append(credential_store.set("system:security_sanitize_enhanced", "1" if body.sanitize_enhanced else "0", "Enhanced prompt injection sanitization (Option 1)"))
        _invalidate_toggle_cache("system:security_sanitize_enhanced")
    if body.canary_enabled is not None:
        ops.append(credential_store.set("system:security_canary_enabled", "1" if body.canary_enabled else "0", "Canary token injection detection (Option 2)"))
        _invalidate_toggle_cache("system:security_canary_enabled")
    if body.output_validation_enabled is not None:
        ops.append(credential_store.set("system:security_output_validation_enabled", "1" if body.output_validation_enabled else "0", "Output validation for external-origin sessions (Option 4)"))
        _invalidate_toggle_cache("system:security_output_validation_enabled")
    if body.truncation_enabled is not None:
        ops.append(credential_store.set("system:security_truncation_enabled", "1" if body.truncation_enabled else "0", "Configurable content truncation limits (Option 5)"))
        _invalidate_toggle_cache("system:security_truncation_enabled")
    if body.max_web_chars is not None:
        ops.append(credential_store.set("system:security_max_web_chars", str(body.max_web_chars), "Max web page chars returned to agent"))
        _limit_cache.pop("system:security_max_web_chars", None)
    if body.max_email_chars is not None:
        ops.append(credential_store.set("system:security_max_email_chars", str(body.max_email_chars), "Max email body chars returned to agent"))
        _limit_cache.pop("system:security_max_email_chars", None)
    if body.max_file_chars is not None:
        ops.append(credential_store.set("system:security_max_file_chars", str(body.max_file_chars), "Max file content chars returned to agent"))
        _limit_cache.pop("system:security_max_file_chars", None)
    if body.max_subject_chars is not None:
        ops.append(credential_store.set("system:security_max_subject_chars", str(body.max_subject_chars), "Max email subject chars returned to agent"))
        _limit_cache.pop("system:security_max_subject_chars", None)
    if body.llm_screen_enabled is not None:
        ops.append(credential_store.set("system:security_llm_screen_enabled", "1" if body.llm_screen_enabled else "0", "LLM content screening for external tool results (Option 3)"))
        _invalidate_toggle_cache("system:security_llm_screen_enabled")
    if body.llm_screen_model is not None:
        ops.append(credential_store.set("system:security_llm_screen_model", body.llm_screen_model, "Model used for LLM content screening"))
    if body.llm_screen_block is not None:
        ops.append(credential_store.set("system:security_llm_screen_block", "1" if body.llm_screen_block else "0", "LLM screening block mode (vs flag mode)"))
        _invalidate_toggle_cache("system:security_llm_screen_block")
    if body.upload_allowed_extensions is not None:
        import json as _json
        exts = [e.lstrip(".").lower().strip() for e in body.upload_allowed_extensions if e.strip()]
        ops.append(credential_store.set("system:upload_allowed_extensions", _json.dumps(exts), "Allowed file extensions for user file uploads"))
    if body.upload_max_file_size_mb is not None:
        ops.append(credential_store.set("system:upload_max_file_size_mb", str(max(1, body.upload_max_file_size_mb)), "Max file size in MB for user uploads"))
    if body.upload_max_files is not None:
        ops.append(credential_store.set("system:upload_max_files", str(max(1, body.upload_max_files)), "Max number of files per upload request"))

    if ops:
        await asyncio_gather(*ops)

    return {"ok": True}


# ── Login lockouts ────────────────────────────────────────────────────────────

@router.get("/settings/login-lockouts")
async def get_login_lockouts(request: Request):
    _require_admin(request)
    from ..login_limiter import list_locked
    return list_locked()


@router.delete("/settings/login-lockouts")
async def unlock_all_ips(request: Request):
    _require_admin(request)
    from ..login_limiter import unlock_all
    count = unlock_all()
    return {"ok": True, "unlocked": count}


@router.delete("/settings/login-lockouts/{ip:path}")
async def unlock_ip(request: Request, ip: str):
    _require_admin(request)
    from ..login_limiter import unlock
    if not unlock(ip):
        raise HTTPException(status_code=404, detail=f"IP {ip!r} not found in lockout list")
    return {"ok": True, "ip": ip}


# ── Branding ───────────────────────────────────────────────────────────────────

@router.get("/settings/branding")
async def get_branding(request: Request):
    _require_admin(request)
    from pathlib import Path
    from ..main import BASE_DIR
    brand_name, logo_filename = await asyncio_gather(
        credential_store.get("system:brand_name"),
        credential_store.get("system:brand_logo_filename"),
    )
    brand_name = brand_name or ""
    logo_filename = logo_filename or ""
    has_custom_logo = bool(logo_filename and (BASE_DIR / "web" / "static" / logo_filename).exists())
    return {
        "brand_name": brand_name,
        "has_custom_logo": has_custom_logo,
        "logo_filename": logo_filename if has_custom_logo else "",
    }


class BrandingNameIn(BaseModel):
    brand_name: str


@router.post("/settings/branding")
async def save_branding(request: Request, body: BrandingNameIn):
    _require_admin(request)
    from ..main import _refresh_brand_globals
    if body.brand_name.strip():
        await credential_store.set("system:brand_name", body.brand_name.strip(), "Custom brand name shown in sidebar")
    else:
        await credential_store.delete("system:brand_name")
    await _refresh_brand_globals()
    return {"ok": True}


@router.post("/settings/branding/logo")
async def upload_brand_logo():
    raise HTTPException(status_code=405, detail="Use multipart POST")


_MAX_LOGO_BYTES = 2 * 1024 * 1024  # 2 MB


@router.post("/settings/branding/logo/upload")
async def upload_brand_logo_file(request: Request):
    _require_admin(request)
    from pathlib import Path
    from ..main import BASE_DIR, _refresh_brand_globals
    form = await request.form()
    file = form.get("file")
    if not file:
        raise HTTPException(status_code=400, detail="No file provided")
    ext = Path(file.filename).suffix.lower() or ".png"
    # SVG excluded: can embed JavaScript and execute when served directly
    if ext not in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
        raise HTTPException(status_code=400, detail="Unsupported image type (png, jpg, gif, webp only)")
    data = await file.read()
    if len(data) > _MAX_LOGO_BYTES:
        raise HTTPException(status_code=413, detail="File too large (max 2 MB)")
    filename = f"logo_custom{ext}"
    dest = BASE_DIR / "web" / "static" / filename
    old_filename = await credential_store.get("system:brand_logo_filename") or ""
    if old_filename and old_filename != filename:
        old_path = BASE_DIR / "web" / "static" / old_filename
        if old_path.exists():
            old_path.unlink(missing_ok=True)
    with dest.open("wb") as f:
        f.write(data)
    await credential_store.set("system:brand_logo_filename", filename, "Custom sidebar logo filename")
    await _refresh_brand_globals()
    return {"ok": True, "filename": filename}


@router.delete("/settings/branding/logo")
async def delete_brand_logo(request: Request):
    _require_admin(request)
    from pathlib import Path
    from ..main import BASE_DIR, _refresh_brand_globals
    filename = await credential_store.get("system:brand_logo_filename") or ""
    if filename:
        path = BASE_DIR / "web" / "static" / filename
        path.unlink(missing_ok=True)
        await credential_store.delete("system:brand_logo_filename")
    await _refresh_brand_globals()
    return {"ok": True}


# ── API Key ────────────────────────────────────────────────────────────────────

@router.get("/settings/api-key")
async def get_api_key_status(request: Request):
    _require_admin(request)
    hash_val, created_at = await asyncio_gather(
        credential_store.get("system:api_key_hash"),
        credential_store.get("system:api_key_created_at"),
    )
    return {"configured": hash_val is not None, "created_at": created_at}


@router.post("/settings/api-key")
async def generate_api_key(request: Request):
    _require_admin(request)
    import hashlib
    import secrets
    raw_key = "oai_" + secrets.token_urlsafe(32)
    key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
    now = datetime.now(timezone.utc).isoformat()
    await asyncio_gather(
        credential_store.set("system:api_key_hash", key_hash, description="API key hash (SHA-256)"),
        credential_store.set("system:api_key_created_at", now, description="API key creation timestamp"),
    )
    return {"key": raw_key}


@router.delete("/settings/api-key")
async def revoke_api_key(request: Request):
    _require_admin(request)
    await asyncio_gather(
        credential_store.delete("system:api_key_hash"),
        credential_store.delete("system:api_key_created_at"),
    )
    return {"ok": True}


# ── Provider API keys (admin — global) ────────────────────────────────────────

_VALID_PROVIDERS = frozenset({"anthropic", "openrouter", "openai"})
_PROVIDER_LABELS = {
    "anthropic": "Anthropic API key",
    "openrouter": "OpenRouter API key",
    "openai": "OpenAI API key",
}


class ProviderKeyIn(BaseModel):
    provider: str  # "anthropic", "openrouter", or "openai"
    key: str


@router.get("/settings/provider-keys")
async def get_provider_keys(request: Request):
    _require_admin(request)
    ant = await credential_store.get("system:anthropic_api_key")
    orr = await credential_store.get("system:openrouter_api_key")
    oai = await credential_store.get("system:openai_api_key")
    return {"anthropic_set": bool(ant), "openrouter_set": bool(orr), "openai_set": bool(oai)}


@router.post("/settings/provider-keys")
async def set_provider_key(request: Request, body: ProviderKeyIn):
    _require_admin(request)
    if body.provider not in _VALID_PROVIDERS:
        raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'")
    if not body.key.strip():
        raise HTTPException(status_code=400, detail="key must not be empty")
    label = _PROVIDER_LABELS[body.provider]
    await credential_store.set(f"system:{body.provider}_api_key", body.key.strip(), label)
    return {"ok": True}


@router.delete("/settings/provider-keys/{provider}")
async def delete_provider_key(request: Request, provider: str):
    _require_admin(request)
    if provider not in _VALID_PROVIDERS:
        raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'")
    await credential_store.delete(f"system:{provider}_api_key")
    return {"ok": True}


# ── Per-user API keys (/api/my/provider-keys) ─────────────────────────────────

from ..database import user_settings_store as _user_settings_store


class MyProviderKeyIn(BaseModel):
    provider: str
    key: str


@router.get("/my/provider-keys")
async def get_my_provider_keys(request: Request):
    user = _require_auth(request)
    ant = await _user_settings_store.get(user.id, "anthropic_api_key")
    orr = await _user_settings_store.get(user.id, "openrouter_api_key")
    oai = await _user_settings_store.get(user.id, "openai_api_key")
    # Determine access tier based on global keys
    global_ant = await credential_store.get("system:anthropic_api_key")
    global_or  = await credential_store.get("system:openrouter_api_key")
    # Admin-granted full access bypasses all restrictions
    use_admin_keys = not user.is_admin and bool(await _user_settings_store.get(user.id, "use_admin_keys"))
    return {
        "anthropic_set": bool(ant),
        "openrouter_set": bool(orr),
        "openai_set": bool(oai),
        # Non-admin access restrictions (lifted if use_admin_keys is granted)
        "anthropic_blocked": not use_admin_keys and not bool(ant) and bool(global_ant) and not user.is_admin,
        "openrouter_free_only": not use_admin_keys and not bool(orr) and bool(global_or) and not user.is_admin,
        "openai_blocked": not use_admin_keys and not user.is_admin,
    }


@router.post("/my/provider-keys")
async def set_my_provider_key(request: Request, body: MyProviderKeyIn):
    user = _require_auth(request)
    if body.provider not in _VALID_PROVIDERS:
        raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'")
    if not body.key.strip():
        raise HTTPException(status_code=400, detail="key must not be empty")
    await _user_settings_store.set(user.id, f"{body.provider}_api_key", body.key.strip())
    return {"ok": True}


@router.delete("/my/provider-keys/{provider}")
async def delete_my_provider_key(request: Request, provider: str):
    user = _require_auth(request)
    if provider not in _VALID_PROVIDERS:
        raise HTTPException(status_code=400, detail="provider must be 'anthropic', 'openrouter', or 'openai'")
    await _user_settings_store.delete(user.id, f"{provider}_api_key")
    return {"ok": True}


# ── Admin: grant/revoke user admin key access ─────────────────────────────────

class AdminKeysAccessIn(BaseModel):
    enabled: bool


@router.get("/users/{user_id}/admin-keys")
async def get_user_admin_keys_access(request: Request, user_id: str):
    _require_admin(request)
    val = await _user_settings_store.get(user_id, "use_admin_keys")
    return {"enabled": bool(val)}


@router.post("/users/{user_id}/admin-keys")
async def set_user_admin_keys_access(request: Request, user_id: str, body: AdminKeysAccessIn):
    _require_admin(request)
    from ..users import get_user_by_id
    if not await get_user_by_id(user_id):
        raise HTTPException(status_code=404, detail="User not found")
    if body.enabled:
        await _user_settings_store.set(user_id, "use_admin_keys", "1")
    else:
        await _user_settings_store.delete(user_id, "use_admin_keys")
    return {"ok": True}


# ── Per-user personality (/api/my/personality) ────────────────────────────────

class MyPersonalityIn(BaseModel):
    soul: Optional[str] = None
    user_context: Optional[str] = None


@router.get("/my/personality")
async def get_my_personality(request: Request):
    user = _require_auth(request)
    soul = await _user_settings_store.get(user.id, "personality_soul") or ""
    user_ctx = await _user_settings_store.get(user.id, "personality_user") or ""
    return {"soul": soul, "user_context": user_ctx}


@router.post("/my/personality")
async def set_my_personality(request: Request, body: MyPersonalityIn):
    user = _require_auth(request)
    if body.soul is not None:
        if body.soul.strip():
            await _user_settings_store.set(user.id, "personality_soul", body.soul.strip())
        else:
            await _user_settings_store.delete(user.id, "personality_soul")
    if body.user_context is not None:
        if body.user_context.strip():
            await _user_settings_store.set(user.id, "personality_user", body.user_context.strip())
        else:
            await _user_settings_store.delete(user.id, "personality_user")
    # Mark profile setup as done when user explicitly saves
    await _user_settings_store.set(user.id, "personality_setup_done", "1")
    return {"ok": True}


@router.post("/my/personality/dismiss-nag")
async def dismiss_personality_nag(request: Request):
    user = _require_auth(request)
    await _user_settings_store.set(user.id, "personality_setup_done", "1")
    return {"ok": True}


# ── Per-user brain settings (/api/my/brain-settings) ─────────────────────────

@router.get("/my/brain-settings")
async def get_my_brain_settings(request: Request):
    user = _require_auth(request)
    val = await _user_settings_store.get(user.id, "brain_auto_approve")
    return {"brain_auto_approve": bool(val)}


@router.post("/my/brain-settings")
async def set_my_brain_settings(request: Request, body: dict):
    user = _require_auth(request)
    enabled = body.get("brain_auto_approve", False)
    if enabled:
        await _user_settings_store.set(user.id, "brain_auto_approve", "1")
    else:
        await _user_settings_store.delete(user.id, "brain_auto_approve")
    return {"ok": True}


# ── Per-user MCP servers (/api/my/mcp-servers) ───────────────────────────────

class MyMcpServerIn(BaseModel):
    name: str
    url: str
    transport: str = "sse"
    api_key: Optional[str] = None
    headers: Optional[dict] = None
    enabled: bool = True


class MyMcpServerUpdate(BaseModel):
    name: Optional[str] = None
    url: Optional[str] = None
    transport: Optional[str] = None
    api_key: Optional[str] = None
    headers: Optional[dict] = None
    enabled: Optional[bool] = None


@router.get("/my/mcp-servers")
async def list_my_mcp_servers(request: Request):
    user = _require_auth(request)
    from ..mcp_client.store import list_servers
    return await list_servers(include_secrets=False, user_id=user.id)


@router.post("/my/mcp-servers", status_code=201)
async def create_my_mcp_server(request: Request, body: MyMcpServerIn):
    user = _require_auth(request)
    from ..mcp_client.store import list_servers, create_server
    existing = await list_servers(user_id=user.id)
    if any(s["name"] == body.name for s in existing):
        raise HTTPException(status_code=400, detail="A server with that name already exists")
    server = await create_server(
        name=body.name, url=body.url, transport=body.transport,
        api_key=body.api_key or "", headers=body.headers, enabled=body.enabled,
        user_id=user.id,
    )
    return server


@router.get("/my/mcp-servers/{server_id}")
async def get_my_mcp_server(request: Request, server_id: str):
    user = _require_auth(request)
    from ..mcp_client.store import get_server
    server = await get_server(server_id, include_secrets=False)
    if not server or server.get("user_id") != user.id:
        raise HTTPException(status_code=404, detail="MCP server not found")
    return server


@router.put("/my/mcp-servers/{server_id}")
async def update_my_mcp_server(request: Request, server_id: str, body: MyMcpServerUpdate):
    user = _require_auth(request)
    from ..mcp_client.store import get_server, update_server
    server = await get_server(server_id)
    if not server or server.get("user_id") != user.id:
        raise HTTPException(status_code=404, detail="MCP server not found")
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    updated = await update_server(server_id, **fields)
    return updated


@router.delete("/my/mcp-servers/{server_id}")
async def delete_my_mcp_server(request: Request, server_id: str):
    user = _require_auth(request)
    from ..mcp_client.store import get_server, delete_server
    server = await get_server(server_id)
    if not server or server.get("user_id") != user.id:
        raise HTTPException(status_code=404, detail="MCP server not found")
    await delete_server(server_id)
    return {"ok": True}


@router.post("/my/mcp-servers/{server_id}/toggle")
async def toggle_my_mcp_server(request: Request, server_id: str):
    user = _require_auth(request)
    from ..mcp_client.store import get_server, update_server
    server = await get_server(server_id)
    if not server or server.get("user_id") != user.id:
        raise HTTPException(status_code=404, detail="MCP server not found")
    updated = await update_server(server_id, enabled=not server["enabled"])
    return updated


# ── Per-user inbox (/api/my/inbox) ────────────────────────────────────────────

_INBOX_CRED_KEYS = [
    "imap_host", "imap_port", "imap_username", "imap_password",
    "smtp_host", "smtp_port", "smtp_username", "smtp_password",
]


@router.get("/my/inbox/config")
async def get_my_inbox_config(request: Request):
    user = _require_auth(request)
    result = {}
    for key in _INBOX_CRED_KEYS:
        val = await _user_settings_store.get(user.id, f"inbox_{key}")
        result[key] = bool(val) if "password" in key else (val or "")
    return result


@router.post("/my/inbox/config")
async def set_my_inbox_config(request: Request):
    user = _require_auth(request)
    body = await request.json()
    for key in _INBOX_CRED_KEYS:
        if key in body:
            val = str(body[key]).strip() if body[key] else ""
            if val:
                await _user_settings_store.set(user.id, f"inbox_{key}", val)
            else:
                await _user_settings_store.delete(user.id, f"inbox_{key}")
    return {"ok": True}


@router.post("/my/inbox/reconnect")
async def reconnect_my_inbox(request: Request):
    user = _require_auth(request)
    from ..inbox.listener import inbox_listener
    inbox_listener.start_for_user(user.id)
    return {"ok": True}


@router.get("/my/inbox/status")
async def get_my_inbox_status(request: Request):
    user = _require_auth(request)
    from ..inbox.listener import inbox_listener
    statuses = inbox_listener.all_statuses()
    # all_statuses() now returns a list; find entries for this user
    user_statuses = [s for s in statuses if s.get("user_id") == user.id]
    if user_statuses:
        return user_statuses[0]
    return {"configured": False, "connected": False, "error": None}


class MyInboxTriggerIn(BaseModel):
    trigger_word: str
    agent_id: str
    description: Optional[str] = ""
    enabled: bool = True


@router.get("/my/inbox/triggers")
async def list_my_inbox_triggers(request: Request):
    user = _require_auth(request)
    from ..inbox.triggers import list_triggers
    return await list_triggers(user_id=user.id)


@router.post("/my/inbox/triggers", status_code=201)
async def create_my_inbox_trigger(request: Request, body: MyInboxTriggerIn):
    user = _require_auth(request)
    from ..inbox.triggers import create_trigger
    return await create_trigger(
        trigger_word=body.trigger_word,
        agent_id=body.agent_id,
        description=body.description or "",
        enabled=body.enabled,
        user_id=user.id,
    )


@router.put("/my/inbox/triggers/{trigger_id}")
async def update_my_inbox_trigger(request: Request, trigger_id: str, body: MyInboxTriggerIn):
    user = _require_auth(request)
    from ..inbox.triggers import update_trigger, list_triggers
    triggers = await list_triggers(user_id=user.id)
    if not any(t["id"] == trigger_id for t in triggers):
        raise HTTPException(status_code=404, detail="Trigger not found")
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    await update_trigger(trigger_id, **fields)
    return {"ok": True}


@router.delete("/my/inbox/triggers/{trigger_id}")
async def delete_my_inbox_trigger(request: Request, trigger_id: str):
    user = _require_auth(request)
    from ..inbox.triggers import delete_trigger, list_triggers
    triggers = await list_triggers(user_id=user.id)
    if not any(t["id"] == trigger_id for t in triggers):
        raise HTTPException(status_code=404, detail="Trigger not found")
    await delete_trigger(trigger_id)
    return {"ok": True}


@router.post("/my/inbox/triggers/{trigger_id}/toggle")
async def toggle_my_inbox_trigger(request: Request, trigger_id: str):
    user = _require_auth(request)
    from ..inbox.triggers import toggle_trigger, list_triggers
    triggers = await list_triggers(user_id=user.id)
    if not any(t["id"] == trigger_id for t in triggers):
        raise HTTPException(status_code=404, detail="Trigger not found")
    await toggle_trigger(trigger_id)
    return {"ok": True}


# ── Per-user Telegram (/api/my/telegram) ──────────────────────────────────────

@router.get("/my/telegram/config")
async def get_my_telegram_config(request: Request):
    user = _require_auth(request)
    token = await _user_settings_store.get(user.id, "telegram_bot_token")
    return {"bot_token_set": bool(token)}


@router.post("/my/telegram/config")
async def set_my_telegram_config(request: Request):
    user = _require_auth(request)
    body = await request.json()
    token = str(body.get("bot_token", "")).strip()
    if token:
        await _user_settings_store.set(user.id, "telegram_bot_token", token)
    else:
        await _user_settings_store.delete(user.id, "telegram_bot_token")
    return {"ok": True}


@router.delete("/my/telegram/config")
async def delete_my_telegram_config(request: Request):
    user = _require_auth(request)
    await _user_settings_store.delete(user.id, "telegram_bot_token")
    from ..telegram.listener import telegram_listener
    telegram_listener.stop_for_user(user.id)
    return {"ok": True}


@router.post("/my/telegram/reconnect")
async def reconnect_my_telegram(request: Request):
    user = _require_auth(request)
    from ..telegram.listener import telegram_listener
    telegram_listener.start_for_user(user.id)
    return {"ok": True}


@router.get("/my/telegram/status")
async def get_my_telegram_status(request: Request):
    user = _require_auth(request)
    from ..telegram.listener import telegram_listener
    statuses = telegram_listener.all_statuses()
    return statuses.get(user.id, {"configured": False, "running": False, "error": None})


@router.get("/my/telegram-whitelist")
async def list_my_telegram_whitelist(request: Request):
    user = _require_auth(request)
    from ..telegram.triggers import list_whitelist
    return await list_whitelist(user_id=user.id)


@router.post("/my/telegram-whitelist")
async def add_my_telegram_whitelist(request: Request):
    user = _require_auth(request)
    body = await request.json()
    chat_id = str(body.get("chat_id", "")).strip()
    label = str(body.get("label", "")).strip()
    if not chat_id:
        raise HTTPException(status_code=400, detail="chat_id is required")
    from ..telegram.triggers import add_to_whitelist
    return await add_to_whitelist(chat_id, label=label, user_id=user.id)


@router.delete("/my/telegram-whitelist/{chat_id}")
async def remove_my_telegram_whitelist(request: Request, chat_id: str):
    user = _require_auth(request)
    from ..telegram.triggers import remove_from_whitelist, list_whitelist
    entries = await list_whitelist(user_id=user.id)
    if not any(e["chat_id"] == chat_id for e in entries):
        raise HTTPException(status_code=404, detail="Chat ID not found in your whitelist")
    await remove_from_whitelist(chat_id, user_id=user.id)
    return {"ok": True}


class MyTelegramTriggerIn(BaseModel):
    trigger_word: str
    agent_id: str
    description: Optional[str] = ""
    enabled: bool = True


@router.get("/my/telegram-triggers")
async def list_my_telegram_triggers(request: Request):
    user = _require_auth(request)
    from ..telegram.triggers import list_triggers
    return await list_triggers(user_id=user.id)


@router.post("/my/telegram-triggers", status_code=201)
async def create_my_telegram_trigger(request: Request, body: MyTelegramTriggerIn):
    user = _require_auth(request)
    from ..telegram.triggers import create_trigger
    return await create_trigger(
        trigger_word=body.trigger_word,
        agent_id=body.agent_id,
        description=body.description or "",
        enabled=body.enabled,
        user_id=user.id,
    )


@router.put("/my/telegram-triggers/{trigger_id}")
async def update_my_telegram_trigger(
    request: Request, trigger_id: str, body: MyTelegramTriggerIn
):
    user = _require_auth(request)
    from ..telegram.triggers import update_trigger, list_triggers
    triggers = await list_triggers(user_id=user.id)
    if not any(t["id"] == trigger_id for t in triggers):
        raise HTTPException(status_code=404, detail="Trigger not found")
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    await update_trigger(trigger_id, **fields)
    return {"ok": True}


@router.delete("/my/telegram-triggers/{trigger_id}")
async def delete_my_telegram_trigger(request: Request, trigger_id: str):
    user = _require_auth(request)
    from ..telegram.triggers import delete_trigger, list_triggers
    triggers = await list_triggers(user_id=user.id)
    if not any(t["id"] == trigger_id for t in triggers):
        raise HTTPException(status_code=404, detail="Trigger not found")
    await delete_trigger(trigger_id)
    return {"ok": True}


@router.post("/my/telegram-triggers/{trigger_id}/toggle")
async def toggle_my_telegram_trigger(request: Request, trigger_id: str):
    user = _require_auth(request)
    from ..telegram.triggers import toggle_trigger, list_triggers
    triggers = await list_triggers(user_id=user.id)
    if not any(t["id"] == trigger_id for t in triggers):
        raise HTTPException(status_code=404, detail="Trigger not found")
    await toggle_trigger(trigger_id)
    return {"ok": True}


# ── Conversations (chat history) ──────────────────────────────────────────────

class ConversationRenameIn(BaseModel):
    title: str


@router.get("/conversations")
async def list_conversations(
    request: Request,
    page: int = 1,
    per_page: int = 40,
    q: str = "",
):
    user = _require_auth(request)
    from ..database import get_pool
    pool = await get_pool()
    offset = (page - 1) * per_page

    # Build WHERE conditions with correct parameter indices
    conditions = ["task_id IS NULL"]
    filter_params: list = []

    # Always scope to the requesting user — admins are not special here
    filter_params.append(user.id)
    conditions.append(f"user_id = ${len(filter_params)}")
    if q:
        filter_params.append(f"%{q}%")
        conditions.append(f"title ILIKE ${len(filter_params)}")

    where = "WHERE " + " AND ".join(conditions)
    n = len(filter_params)

    total = await pool.fetchval(
        f"SELECT COUNT(*) FROM conversations {where}",
        *filter_params,
    )
    rows = await pool.fetch(
        f"""SELECT id, title, model, started_at, ended_at, task_id,
                   CASE WHEN jsonb_typeof(messages) = 'array'
                        THEN jsonb_array_length(messages) ELSE 0 END AS message_count
            FROM conversations
            {where}
            ORDER BY ended_at DESC NULLS LAST
            LIMIT ${n + 1} OFFSET ${n + 2}""",
        *filter_params, per_page, offset,
    )
    return {"conversations": [dict(r) for r in rows], "total": total, "page": page, "per_page": per_page}


@router.patch("/conversations/{conv_id}")
async def rename_conversation(request: Request, conv_id: str, body: ConversationRenameIn):
    user = _require_auth(request)
    from ..database import get_pool
    pool = await get_pool()
    row = await pool.fetchrow("SELECT user_id FROM conversations WHERE id = $1", conv_id)
    if not row:
        raise HTTPException(status_code=404, detail="Conversation not found")
    if not user.is_admin and row["user_id"] != user.id:
        raise HTTPException(status_code=403, detail="Not your conversation")
    title = body.title.strip()[:120] or "Chat"
    await pool.execute("UPDATE conversations SET title = $1 WHERE id = $2", title, conv_id)
    return {"ok": True, "title": title}


@router.delete("/conversations/{conv_id}")
async def delete_conversation(request: Request, conv_id: str):
    user = _require_auth(request)
    from ..database import get_pool
    pool = await get_pool()
    row = await pool.fetchrow("SELECT user_id FROM conversations WHERE id = $1", conv_id)
    if not row:
        raise HTTPException(status_code=404, detail="Conversation not found")
    if not user.is_admin and row["user_id"] != user.id:
        raise HTTPException(status_code=403, detail="Not your conversation")
    await pool.execute("DELETE FROM conversations WHERE id = $1", conv_id)
    return {"ok": True}


@router.get("/conversations/{conv_id}/export")
async def export_conversation(request: Request, conv_id: str, format: str = "markdown"):
    """Download a conversation as Markdown or JSON."""
    import json as _json
    from fastapi.responses import Response
    from datetime import datetime, timezone

    user = _require_auth(request)
    from ..database import get_pool
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT * FROM conversations WHERE id = $1", conv_id
    )
    if not row:
        raise HTTPException(status_code=404, detail="Conversation not found")
    if not user.is_admin and row.get("user_id") != user.id:
        raise HTTPException(status_code=403, detail="Not your conversation")

    messages = row.get("messages") or []
    if isinstance(messages, str):
        try:
            messages = _json.loads(messages)
        except Exception:
            messages = []

    started_at = row.get("started_at") or ""
    title = row.get("title") or "Conversation"
    model = row.get("model") or ""

    from ..config import settings as _settings
    agent_name = _settings.agent_name

    if format == "json":
        content = _json.dumps({
            "id": str(row["id"]),
            "title": title,
            "model": model,
            "started_at": str(started_at),
            "messages": messages,
        }, indent=2, default=str)
        filename = f"conversation-{conv_id[:8]}.json"
        media_type = "application/json"
    else:
        # Markdown
        lines = [f"# {title}", ""]
        if model:
            lines.append(f"**Model:** {model}  ")
        if started_at:
            lines.append(f"**Date:** {str(started_at)[:19]}  ")
        lines += ["", "---", ""]
        for msg in messages:
            role = msg.get("role", "")
            content_parts = msg.get("content", "")
            if isinstance(content_parts, list):
                text = " ".join(
                    p.get("text", "") for p in content_parts
                    if isinstance(p, dict) and p.get("type") == "text"
                )
            else:
                text = str(content_parts)
            if not text.strip():
                continue
            speaker = "You" if role == "user" else agent_name
            lines += [f"**{speaker}**", "", text, "", "---", ""]
        content = "\n".join(lines)
        filename = f"conversation-{conv_id[:8]}.md"
        media_type = "text/markdown"

    return Response(
        content=content,
        media_type=media_type,
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


# ── Agent templates ────────────────────────────────────────────────────────────

@router.get("/agent-templates")
async def list_agent_templates():
    from ..agent_templates import list_templates
    return list_templates()


@router.get("/agent-templates/{template_id}")
async def get_agent_template(template_id: str):
    from ..agent_templates import get_template
    t = get_template(template_id)
    if not t:
        raise HTTPException(status_code=404, detail="Template not found")
    return t


# ── User management (admin only) ───────────────────────────────────────────────

class UserCreateIn(BaseModel):
    username: str
    password: str
    email: str
    role: str = "user"


class UserUpdateIn(BaseModel):
    role: Optional[str] = None
    is_active: Optional[bool] = None
    password: Optional[str] = None
    email: Optional[str] = None


class ChangePasswordIn(BaseModel):
    current_password: str
    new_password: str


def _require_admin(request: Request):
    """Raise 403 if the current request is not from an admin."""
    u = getattr(request.state, "current_user", None)
    if u is None or u.role != "admin":
        raise HTTPException(status_code=403, detail="Admin access required")
    return u


def _require_auth(request: Request):
    """Raise 401 if not authenticated."""
    u = getattr(request.state, "current_user", None)
    if u is None:
        raise HTTPException(status_code=401, detail="Authentication required")
    return u


def _check_agent_access(agent: dict | None, user) -> dict:
    """Raise 404 if the agent doesn't exist or the non-admin user doesn't own it.
    Always returns 404 (not 403) to avoid leaking existence of other users' agents."""
    if agent is None:
        raise HTTPException(status_code=404, detail="Agent not found")
    if not user.is_admin and agent.get("owner_user_id") != user.id:
        raise HTTPException(status_code=404, detail="Agent not found")
    return agent


@router.get("/users")
async def list_users_api(request: Request):
    _require_admin(request)
    from ..users import list_users
    return await list_users()


@router.post("/users", status_code=201)
async def create_user_api(request: Request, body: UserCreateIn):
    _require_admin(request)
    if len(body.password) < 8:
        raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
    if body.role not in ("admin", "user"):
        raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
    email = body.email.strip().lower()
    if not email or "@" not in email:
        raise HTTPException(status_code=400, detail="A valid email address is required")
    from ..users import create_user, get_user_by_username
    if await get_user_by_username(body.username):
        raise HTTPException(status_code=409, detail="Username already exists")
    return await create_user(body.username, body.password, body.role, email)


@router.put("/users/{user_id}")
async def update_user_api(request: Request, user_id: str, body: UserUpdateIn):
    admin = _require_admin(request)
    if user_id == admin.id and body.is_active is False:
        raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
    from ..users import update_user, get_user_by_id
    if not await get_user_by_id(user_id):
        raise HTTPException(status_code=404, detail="User not found")
    fields = {}
    if body.role is not None:
        if body.role not in ("admin", "user"):
            raise HTTPException(status_code=400, detail="role must be 'admin' or 'user'")
        fields["role"] = body.role
    if body.is_active is not None:
        fields["is_active"] = body.is_active
    if body.password is not None:
        if len(body.password) < 8:
            raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
        fields["password"] = body.password
    if body.email is not None:
        email = body.email.strip().lower()
        if email and "@" not in email:
            raise HTTPException(status_code=400, detail="Invalid email address")
        fields["email"] = email or None
    if not fields:
        raise HTTPException(status_code=400, detail="No fields to update")
    await update_user(user_id, **fields)

    # Stop or restart automated listeners when is_active changes
    if "is_active" in fields:
        from ..inbox.listener import inbox_listener
        from ..telegram.listener import telegram_listener
        if fields["is_active"]:
            inbox_listener.start_for_user(user_id)
            telegram_listener.start_for_user(user_id)
        else:
            inbox_listener.stop_for_user(user_id)
            telegram_listener.stop_for_user(user_id)

    from ..users import get_user_by_id
    return await get_user_by_id(user_id)


@router.delete("/users/{user_id}")
async def delete_user_api(request: Request, user_id: str):
    admin = _require_admin(request)
    if user_id == admin.id:
        raise HTTPException(status_code=400, detail="Cannot delete your own account")
    from ..users import delete_user
    if not await delete_user(user_id):
        raise HTTPException(status_code=404, detail="User not found")
    return {"ok": True}


@router.post("/users/me/password")
async def change_own_password(request: Request, body: ChangePasswordIn):
    u = _require_auth(request)  # sets u from request.state
    from ..auth import verify_password
    from ..users import get_user_by_username, update_user
    full = await get_user_by_username(u.username)
    if not full or not verify_password(body.current_password, full["password_hash"]):
        raise HTTPException(status_code=400, detail="Current password is incorrect")
    if len(body.new_password) < 8:
        raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
    await update_user(u.id, password=body.new_password)
    return {"ok": True}


# ── Helper: thin wrapper around asyncio.gather for use inside routes ───────────

# ── Email accounts (per-user handling + trigger accounts) ─────────────────────

@router.get("/my/email-accounts")
async def list_my_email_accounts(request: Request):
    user = _require_auth(request)
    from ..inbox.accounts import list_accounts, mask_account
    accounts = await list_accounts(user_id=user.id)
    return [mask_account(a) for a in accounts]


@router.post("/my/email-accounts", status_code=201)
async def create_my_email_account(request: Request):
    user = _require_auth(request)
    body = await request.json()
    from ..inbox.accounts import create_account, mask_account
    from ..agents.tasks import create_agent

    label = body.get("label", "")
    agent_model = body.get("agent_model", "").strip()
    agent_prompt = body.get("agent_prompt", "").strip()

    agent_max_tool_calls = body.get("agent_max_tool_calls")
    if agent_max_tool_calls is not None:
        try:
            agent_max_tool_calls = int(agent_max_tool_calls)
        except (TypeError, ValueError):
            agent_max_tool_calls = None
    agent_prompt_mode = body.get("agent_prompt_mode") or "combined"

    # Auto-create a dedicated agent for this account
    agent = await create_agent(
        name=f"Email Handler: {label}",
        prompt=agent_prompt,
        model=agent_model,
        allowed_tools=["email"],
        created_by="user",
        owner_user_id=user.id,
        max_tool_calls=agent_max_tool_calls,
        prompt_mode=agent_prompt_mode,
    )
    agent_id = agent["id"]

    acct = await create_account(
        label=label,
        account_type="handling",
        imap_host=body.get("imap_host", ""),
        imap_port=int(body.get("imap_port") or 993),
        imap_username=body.get("imap_username", ""),
        imap_password=body.get("imap_password", ""),
        smtp_host=body.get("smtp_host") or None,
        smtp_port=int(body.get("smtp_port")) if body.get("smtp_port") else None,
        smtp_username=body.get("smtp_username") or None,
        smtp_password=body.get("smtp_password") or None,
        agent_id=agent_id,
        user_id=user.id,
        initial_load_limit=int(body.get("initial_load_limit") or 200),
        monitored_folders=body.get("monitored_folders") or ["INBOX"],
        extra_tools=body.get("extra_tools") or [],
        telegram_chat_id=body.get("telegram_chat_id") or None,
        telegram_keyword=body.get("telegram_keyword") or None,
        enabled=body.get("enabled", True),
    )
    return mask_account(acct)


@router.put("/my/email-accounts/{account_id}")
async def update_my_email_account(request: Request, account_id: str):
    user = _require_auth(request)
    from ..inbox.accounts import get_account, update_account, mask_account
    from ..agents.tasks import update_agent
    acct = await get_account(account_id)
    if not acct or str(acct.get("user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Account not found")
    body = await request.json()

    # Update the linked agent's model, prompt, and cost-control fields if provided
    if acct.get("agent_id"):
        agent_fields = {}
        if "agent_model" in body:
            agent_fields["model"] = body["agent_model"]
        if "agent_prompt" in body:
            agent_fields["prompt"] = body["agent_prompt"]
        if "label" in body:
            agent_fields["name"] = f"Email Handler: {body['label']}"
        if "agent_max_tool_calls" in body:
            v = body["agent_max_tool_calls"]
            agent_fields["max_tool_calls"] = int(v) if v is not None else None
        if "agent_prompt_mode" in body:
            agent_fields["prompt_mode"] = body["agent_prompt_mode"] or "combined"
        if agent_fields:
            await update_agent(acct["agent_id"], **agent_fields)

    allowed_fields = {
        "label", "imap_host", "imap_port", "imap_username", "imap_password",
        "smtp_host", "smtp_port", "smtp_username", "smtp_password",
        "enabled", "initial_load_limit", "monitored_folders", "extra_tools",
        "telegram_chat_id", "telegram_keyword",
    }
    updates = {k: v for k, v in body.items() if k in allowed_fields}
    await update_account(account_id, **updates)
    updated = await get_account(account_id)
    return mask_account(updated)


@router.delete("/my/email-accounts/{account_id}")
async def delete_my_email_account(request: Request, account_id: str):
    user = _require_auth(request)
    from ..inbox.accounts import get_account, delete_account
    from ..inbox.listener import inbox_listener
    from ..agents.tasks import delete_agent
    acct = await get_account(account_id)
    if not acct or str(acct.get("user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Account not found")
    inbox_listener.stop_account(account_id)
    linked_agent_id = acct.get("agent_id")
    await delete_account(account_id)
    if linked_agent_id:
        await delete_agent(linked_agent_id)
    return {"ok": True}


@router.post("/my/email-accounts/{account_id}/toggle")
async def toggle_my_email_account(request: Request, account_id: str):
    user = _require_auth(request)
    from ..inbox.accounts import get_account, toggle_account, mask_account
    from ..inbox.listener import inbox_listener
    acct = await get_account(account_id)
    if not acct or str(acct.get("user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Account not found")
    await toggle_account(account_id)
    updated = await get_account(account_id)
    if updated.get("enabled"):
        inbox_listener.start_account(account_id, updated)
    else:
        inbox_listener.stop_account(account_id)
    return mask_account(updated)


@router.post("/my/email-accounts/{account_id}/reconnect")
async def reconnect_my_email_account(request: Request, account_id: str):
    user = _require_auth(request)
    from ..inbox.accounts import get_account
    from ..inbox.listener import inbox_listener
    acct = await get_account(account_id)
    if not acct or str(acct.get("user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Account not found")
    inbox_listener.restart_account(account_id, acct)
    return {"ok": True}


@router.post("/my/email-accounts/{account_id}/pause")
async def pause_my_email_account(request: Request, account_id: str):
    user = _require_auth(request)
    from ..inbox.accounts import get_account, pause_account
    from ..inbox.listener import inbox_listener
    acct = await get_account(account_id)
    if not acct or str(acct.get("user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Account not found")
    await pause_account(account_id)
    inbox_listener.stop_account(account_id)
    return {"ok": True, "paused": True}


@router.post("/my/email-accounts/{account_id}/resume")
async def resume_my_email_account(request: Request, account_id: str):
    user = _require_auth(request)
    from ..inbox.accounts import get_account, resume_account
    from ..inbox.listener import inbox_listener
    acct = await get_account(account_id)
    if not acct or str(acct.get("user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Account not found")
    await resume_account(account_id)
    updated = await get_account(account_id)
    if updated and updated.get("enabled"):
        inbox_listener.start_account(account_id, updated)
    return {"ok": True, "paused": False}


# ── Per-user Brain MCP key ────────────────────────────────────────────────────

import secrets as _secrets


async def _get_or_create_brain_key(user_id: str) -> str:
    """Return the user's brain MCP key, generating one if it doesn't exist."""
    key = await _user_settings_store.get(user_id, "brain_mcp_key")
    if not key:
        key = _secrets.token_hex(32)
        await _user_settings_store.set(user_id, "brain_mcp_key", key)
    return key


@router.get("/my/brain/key")
async def get_my_brain_key(request: Request):
    user = _require_auth(request)
    key = await _get_or_create_brain_key(user.id)
    return {"key": key}


@router.post("/my/brain/key/regenerate")
async def regenerate_my_brain_key(request: Request):
    user = _require_auth(request)
    key = _secrets.token_hex(32)
    await _user_settings_store.set(user.id, "brain_mcp_key", key)
    return {"key": key}


@router.get("/my/data-folder")
async def get_my_data_folder(request: Request):
    """Return the auto-provisioned user folder path (read-only, derived from system:users_base_folder)."""
    user = _require_auth(request)
    from ..users import get_user_folder
    folder = await get_user_folder(user.id)
    return {"data_folder": folder or ""}


# ── Per-user SSH key ───────────────────────────────────────────────────────────

@router.get("/my/ssh/pubkey")
async def get_my_ssh_pubkey(request: Request):
    user = _require_auth(request)
    from ..users import get_user_folder
    from pathlib import Path
    folder = await get_user_folder(user.id)
    if not folder:
        return {"exists": False, "pubkey": None, "no_folder": True}
    pubkey_path = Path(folder) / ".ssh" / "id_ed25519.pub"
    if not pubkey_path.exists():
        return {"exists": False, "pubkey": None}
    return {"exists": True, "pubkey": pubkey_path.read_text().strip()}


@router.post("/my/ssh/generate")
async def generate_my_ssh_key(request: Request):
    import asyncio
    from pathlib import Path
    user = _require_auth(request)
    body = await request.json()
    force = bool(body.get("force", False))
    from ..users import get_user_folder
    folder = await get_user_folder(user.id)
    if not folder:
        raise HTTPException(status_code=400, detail="No user folder configured. Ask your administrator to set system:users_base_folder.")
    ssh_dir = Path(folder) / ".ssh"
    key_path = ssh_dir / "id_ed25519"
    pubkey_path = ssh_dir / "id_ed25519.pub"
    if key_path.exists() and not force:
        raise HTTPException(status_code=409, detail="SSH key already exists.")
    ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
    if force:
        key_path.unlink(missing_ok=True)
        pubkey_path.unlink(missing_ok=True)
    email = getattr(user, "email", "") or getattr(user, "username", "agent")
    proc = await asyncio.create_subprocess_exec(
        "ssh-keygen", "-t", "ed25519", "-C", email,
        "-f", str(key_path), "-N", "",
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    _, stderr = await proc.communicate()
    if proc.returncode != 0:
        raise HTTPException(status_code=500, detail=f"ssh-keygen failed: {stderr.decode()}")
    key_path.chmod(0o600)
    pubkey_path.chmod(0o644)
    ssh_dir.chmod(0o700)
    return {"pubkey": pubkey_path.read_text().strip()}


# ── Per-user file browser ──────────────────────────────────────────────────────

import io as _io
import os as _os
import zipfile as _zipfile
from datetime import datetime as _dt, timezone as _tz

from fastapi.responses import FileResponse, StreamingResponse


def _resolve_user_path(base: str, rel_path: str) -> str:
    """Resolve rel_path within base; raise 403 on path traversal attempts."""
    real_base = _os.path.realpath(base)
    joined = _os.path.join(real_base, rel_path.lstrip("/\\")) if rel_path else real_base
    real_target = _os.path.realpath(joined)
    if real_target != real_base and not real_target.startswith(real_base + _os.sep):
        raise HTTPException(status_code=403, detail="Access denied")
    return real_target


def _fmt_size(n: int) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit == "B" else f"{n/1024:.1f} {unit}"
        n /= 1024
    return str(n)


@router.get("/my/files")
async def list_my_files(request: Request, path: str = ""):
    user = _require_auth(request)
    from ..users import get_user_folder
    base = await get_user_folder(user.id)
    if not base:
        raise HTTPException(status_code=404, detail="no_folder")
    if not _os.path.isdir(base):
        raise HTTPException(status_code=404, detail="folder_missing")

    target = _resolve_user_path(base, path)
    if not _os.path.exists(target):
        raise HTTPException(status_code=404, detail="Path not found")
    if not _os.path.isdir(target):
        raise HTTPException(status_code=400, detail="Path is not a directory")

    real_base = _os.path.realpath(base)
    entries = []
    try:
        items = sorted(_os.scandir(target), key=lambda e: (not e.is_dir(), e.name.lower()))
        for entry in items:
            try:
                stat = entry.stat(follow_symlinks=False)
                rel = _os.path.relpath(entry.path, real_base)
                entries.append({
                    "name": entry.name,
                    "path": rel,
                    "is_dir": entry.is_dir(),
                    "size": stat.st_size if not entry.is_dir() else None,
                    "modified": _dt.fromtimestamp(stat.st_mtime, tz=_tz.utc).isoformat(),
                })
            except OSError:
                continue
    except PermissionError:
        pass

    rel_current = _os.path.relpath(target, real_base)
    if target == real_base:
        rel_parent = None
    else:
        p = _os.path.relpath(_os.path.dirname(target), real_base)
        rel_parent = "" if p == "." else p

    return {
        "path": "" if rel_current == "." else rel_current,
        "parent": rel_parent,
        "entries": entries,
    }


@router.get("/my/files/download")
async def download_my_file(request: Request, path: str):
    user = _require_auth(request)
    from ..users import get_user_folder
    base = await get_user_folder(user.id)
    if not base:
        raise HTTPException(status_code=404, detail="No files folder configured")
    target = _resolve_user_path(base, path)
    if not _os.path.isfile(target):
        raise HTTPException(status_code=404, detail="File not found")
    return FileResponse(
        target,
        filename=_os.path.basename(target),
        media_type="application/octet-stream",
    )


_FB_TEXT_EXTS = {
    ".md", ".txt", ".json", ".xml", ".yaml", ".yml", ".csv",
    ".html", ".htm", ".css", ".js", ".ts", ".jsx", ".tsx",
    ".py", ".sh", ".bash", ".zsh", ".log", ".sql", ".toml",
    ".ini", ".conf", ".cfg", ".env", ".gitignore", ".dockerfile",
    ".rst", ".tex", ".diff", ".patch", ".nfo", ".tsv",
}


@router.get("/my/files/view")
async def view_my_file(request: Request, path: str):
    user = _require_auth(request)
    from ..users import get_user_folder
    base = await get_user_folder(user.id)
    if not base:
        raise HTTPException(status_code=404, detail="No files folder configured")
    target = _resolve_user_path(base, path)
    if not _os.path.isfile(target):
        raise HTTPException(status_code=404, detail="File not found")
    ext = _os.path.splitext(target)[1].lower()
    if ext not in _FB_TEXT_EXTS:
        raise HTTPException(status_code=415, detail="File type not supported for viewing")
    size = _os.path.getsize(target)
    _MAX_VIEW = 512 * 1024  # 512 KB
    try:
        with open(target, "r", encoding="utf-8", errors="replace") as fh:
            content = fh.read(_MAX_VIEW)
    except OSError as exc:
        raise HTTPException(status_code=500, detail=str(exc))
    return {"content": content, "size": size, "truncated": size > _MAX_VIEW}


@router.get("/my/files/download-zip")
async def download_my_zip(request: Request, path: str = ""):
    user = _require_auth(request)
    from ..users import get_user_folder
    base = await get_user_folder(user.id)
    if not base:
        raise HTTPException(status_code=404, detail="No files folder configured")
    target = _resolve_user_path(base, path)
    if not _os.path.isdir(target):
        raise HTTPException(status_code=400, detail="Path is not a directory")

    folder_name = _os.path.basename(target) or user.username

    def _make_zip() -> bytes:
        buf = _io.BytesIO()
        with _zipfile.ZipFile(buf, "w", _zipfile.ZIP_DEFLATED) as zf:
            for root, dirs, files in _os.walk(target):
                dirs[:] = sorted(dirs)
                for fname in sorted(files):
                    fpath = _os.path.join(root, fname)
                    arcname = _os.path.relpath(fpath, target)
                    try:
                        zf.write(fpath, arcname)
                    except OSError:
                        pass
        return buf.getvalue()

    import asyncio as _asyncio2
    zip_bytes = await _asyncio2.get_event_loop().run_in_executor(None, _make_zip)
    return StreamingResponse(
        iter([zip_bytes]),
        media_type="application/zip",
        headers={"Content-Disposition": f'attachment; filename="{folder_name}.zip"'},
    )


@router.get("/my/files/upload-policy")
async def get_upload_policy(request: Request):
    _require_auth(request)
    return await _get_upload_policy()


@router.post("/my/files/upload")
async def upload_my_files(request: Request, path: str = ""):
    user = _require_auth(request)
    from ..users import get_user_folder
    base = await get_user_folder(user.id)
    if not base:
        raise HTTPException(status_code=404, detail="No files folder configured")
    target_dir = _resolve_user_path(base, path)
    if not _os.path.isdir(target_dir):
        raise HTTPException(status_code=400, detail="Target path is not a directory")

    policy = await _get_upload_policy()
    allowed_exts = {e.lower() for e in policy["allowed_extensions"]}
    max_bytes = policy["max_file_size_mb"] * 1024 * 1024
    max_files = policy["max_files"]

    form = await request.form()
    fields = [f for f in form.values() if hasattr(f, "filename") and f.filename]
    if len(fields) > max_files:
        raise HTTPException(status_code=400, detail=f"Too many files — max {max_files} per upload")

    uploaded = []
    rejected = []
    for field in fields:
        safe_name = _os.path.basename(field.filename)
        if not safe_name:
            continue
        has_ext = "." in safe_name
        ext = safe_name.rsplit(".", 1)[-1].lower() if has_ext else ""
        name_lower = safe_name.lower()
        allowed = (ext in allowed_exts) if has_ext else (name_lower in _UPLOAD_ALLOWED_EXACT_NAMES)
        if not allowed:
            reason = f"File type .{ext} not allowed" if has_ext else f"Extensionless file '{safe_name}' not permitted"
            rejected.append({"name": safe_name, "reason": reason})
            continue
        content = await field.read()
        if len(content) > max_bytes:
            rejected.append({"name": safe_name, "reason": f"File exceeds {policy['max_file_size_mb']} MB limit"})
            continue
        dest = _resolve_user_path(base, _os.path.join(path, safe_name))
        with open(dest, "wb") as f:
            f.write(content)
        uploaded.append(safe_name)

    return {"ok": True, "uploaded": uploaded, "rejected": rejected}


@router.delete("/my/files")
async def delete_my_file(request: Request, path: str):
    """Delete a file from the user's data folder.

    Protected files (memory_*, reasoning_*) can never be deleted via this endpoint.
    TODO: replace hard-coded prefixes with a .jarvisignore-style mechanism.
    """
    user = _require_auth(request)
    from ..users import get_user_folder
    base = await get_user_folder(user.id)
    if not base:
        raise HTTPException(status_code=404, detail="No files folder configured")
    target = _resolve_user_path(base, path)
    name = _os.path.basename(target)
    if name.startswith("memory_") or name.startswith("reasoning_"):
        raise HTTPException(status_code=403, detail=f"'{name}' is a protected file and cannot be deleted")
    if not _os.path.exists(target):
        raise HTTPException(status_code=404, detail="File not found")
    if _os.path.isdir(target):
        raise HTTPException(status_code=400, detail="Use a dedicated endpoint to delete directories")
    _os.remove(target)
    return {"ok": True}


@router.get("/my/email-accounts/available-extra-tools")
async def available_extra_tools(request: Request):
    """Return which notification tools are configured and available for email handling accounts."""
    user = _require_auth(request)
    available = []
    # Telegram: check user's own per-user token; admins also see global token
    tg_token = await _user_settings_store.get(user.id, "telegram_bot_token")
    if not tg_token and user.role == "admin":
        tg_token = await credential_store.get("telegram:bot_token")
    if tg_token:
        available.append({"id": "telegram", "label": "Telegram", "description": "Send Telegram messages to whitelisted chats"})
    # Pushover: global admin credential — only available to admin users
    if user.role == "admin":
        pv_key = await credential_store.get("pushover_app_token")
        pv_user = await credential_store.get("pushover_user_key")
        if pv_key and pv_user:
            available.append({"id": "pushover", "label": "Pushover", "description": "Send push notifications to your phone"})
    return available


@router.get("/my/telegram/whitelisted-chats")
async def my_whitelisted_telegram_chats(request: Request):
    """Return Telegram chat IDs the current user has whitelisted (for email account binding)."""
    user = _require_auth(request)
    from ..telegram.triggers import list_whitelist
    user_chats = await list_whitelist(user.id)
    global_chats = await list_whitelist("GLOBAL")
    # Merge, deduplicate by chat_id, user-specific first
    seen: set[str] = set()
    result = []
    for row in user_chats + global_chats:
        if row["chat_id"] not in seen:
            seen.add(row["chat_id"])
            result.append({"chat_id": row["chat_id"], "label": row.get("label") or row["chat_id"]})
    return result


@router.post("/my/email-accounts/list-folders-preview")
async def list_folders_preview(request: Request):
    """Load folder list using credentials supplied directly (for new accounts)."""
    _require_auth(request)
    body = await request.json()
    host = body.get("imap_host", "").strip()
    port = int(body.get("imap_port") or 993)
    username = body.get("imap_username", "").strip()
    password = body.get("imap_password", "")
    if not host or not username or not password:
        raise HTTPException(status_code=400, detail="host, username and password are required")
    from ..tools.email_handling_tool import EmailHandlingTool
    tool = EmailHandlingTool(account={
        "imap_host": host, "imap_port": port,
        "imap_username": username, "imap_password": password,
        "monitored_folders": None,  # show all folders for picker
    })
    result = await tool._list_folders()
    if not result.success:
        raise HTTPException(status_code=400, detail=result.error)
    return result.data


@router.post("/my/email-accounts/{account_id}/list-folders")
async def list_folders_for_account(request: Request, account_id: str):
    """Open a temporary IMAP connection and return the folder tree."""
    user = _require_auth(request)
    from ..inbox.accounts import get_account
    from ..tools.email_handling_tool import EmailHandlingTool
    acct = await get_account(account_id)
    if not acct or str(acct.get("user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Account not found")
    # Show all server folders for the picker (not filtered to current selection)
    tool = EmailHandlingTool(account=dict(acct, monitored_folders=None))
    result = await tool._list_folders()
    if not result.success:
        raise HTTPException(status_code=400, detail=result.error)
    return result.data


# ── Per-user CalDAV config ─────────────────────────────────────────────────────

@router.get("/my/caldav/config")
async def get_my_caldav_config(request: Request):
    user = _require_auth(request)
    get = lambda k: _user_settings_store.get(user.id, k)
    url           = await get("caldav_url")
    username      = await get("caldav_username")
    password      = await get("caldav_password")
    calendar_name = await get("caldav_calendar_name")
    carddav_same  = await get("carddav_same_as_caldav")
    carddav_url   = await get("carddav_url")
    carddav_user  = await get("carddav_username")
    carddav_pass  = await get("carddav_password")
    allow_write   = await get("contacts_allow_write")
    return {
        "url": url or "",
        "username": username or "",
        "password": password or "",
        "calendar_name": calendar_name or "",
        "carddav_same_as_caldav": carddav_same == "1",
        "carddav_url": carddav_url or "",
        "carddav_username": carddav_user or "",
        "carddav_password": carddav_pass or "",
        "contacts_allow_write": allow_write == "1",
    }


@router.post("/my/caldav/config")
async def set_my_caldav_config(request: Request):
    user = _require_auth(request)
    body = await request.json()

    # CalDAV fields
    for key, setting_key in [
        ("url", "caldav_url"),
        ("username", "caldav_username"),
        ("password", "caldav_password"),
        ("calendar_name", "caldav_calendar_name"),
    ]:
        val = (body.get(key) or "").strip()
        if val:
            await _user_settings_store.set(user.id, setting_key, val)
        elif key != "password":
            await _user_settings_store.delete(user.id, setting_key)

    # CardDAV fields
    same = bool(body.get("carddav_same_as_caldav"))
    if same:
        await _user_settings_store.set(user.id, "carddav_same_as_caldav", "1")
        # Clear separate CardDAV creds — not needed when using same server
        for k in ("carddav_url", "carddav_username", "carddav_password"):
            await _user_settings_store.delete(user.id, k)
    else:
        await _user_settings_store.delete(user.id, "carddav_same_as_caldav")
        for key, setting_key in [
            ("carddav_url", "carddav_url"),
            ("carddav_username", "carddav_username"),
            ("carddav_password", "carddav_password"),
        ]:
            val = (body.get(key) or "").strip()
            if val:
                await _user_settings_store.set(user.id, setting_key, val)
            elif key != "carddav_password":
                await _user_settings_store.delete(user.id, setting_key)

    # Per-user contacts write permission
    allow_write = bool(body.get("contacts_allow_write"))
    if allow_write:
        await _user_settings_store.set(user.id, "contacts_allow_write", "1")
    else:
        await _user_settings_store.delete(user.id, "contacts_allow_write")

    return {"ok": True}


@router.post("/my/caldav/test")
async def test_my_caldav_config(request: Request):
    user = _require_auth(request)
    from ..tools.caldav_tool import _get_caldav_config
    cfg = await _get_caldav_config(user_id=user.id)
    if not cfg.get("url") or not cfg.get("username") or not cfg.get("password"):
        return {"success": False, "message": "CalDAV credentials not configured"}
    try:
        import caldav
        url = cfg["url"]
        if not url.startswith(("http://", "https://")):
            url = "https://" + url
        if "/SOGo/dav/" not in url:
            url = f"{url.rstrip('/')}/SOGo/dav/{cfg['username']}/"
        client = caldav.DAVClient(url=url, username=cfg["username"], password=cfg["password"])
        principal = client.principal()
        calendars = principal.calendars()
        return {"success": True, "message": f"Connected — {len(calendars)} calendar(s) found"}
    except Exception as e:
        return {"success": False, "message": str(e)}


@router.post("/my/caldav/test-carddav")
async def test_my_carddav_config(request: Request):
    user = _require_auth(request)
    from ..tools.contacts_tool import _get_carddav_config, _sogo_carddav_url
    cfg = await _get_carddav_config(user_id=user.id)
    if not cfg.get("url") or not cfg.get("username") or not cfg.get("password"):
        return {"success": False, "message": "CardDAV credentials not configured"}
    try:
        import httpx
        abook_url = _sogo_carddav_url(cfg["url"], cfg["username"])
        body = (
            '<?xml version="1.0" encoding="utf-8"?>'
            '<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>'
        )
        async with httpx.AsyncClient(
            auth=(cfg["username"], cfg["password"]), timeout=10
        ) as client:
            r = await client.request(
                "PROPFIND", abook_url,
                content=body,
                headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
            )
        if r.status_code in (200, 207):
            return {"success": True, "message": f"Connected to {abook_url}"}
        return {"success": False, "message": f"Server returned HTTP {r.status_code}"}
    except Exception as e:
        return {"success": False, "message": str(e)}


@router.delete("/my/caldav/config")
async def delete_my_caldav_config(request: Request):
    user = _require_auth(request)
    for key in (
        "caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name",
        "carddav_same_as_caldav", "carddav_url", "carddav_username", "carddav_password",
        "contacts_allow_write",
    ):
        await _user_settings_store.delete(user.id, key)
    return {"ok": True}


# ── Inbox all-accounts status (admin) ─────────────────────────────────────────

@router.get("/inbox/accounts")
async def list_inbox_accounts(request: Request):
    _require_admin(request)
    from ..inbox.accounts import list_accounts, mask_account
    accounts = await list_accounts()
    return [mask_account(a) for a in accounts]


import asyncio as _asyncio


async def asyncio_gather(*coros):
    return await _asyncio.gather(*coros)


# ── MFA (TOTP) setup & management ─────────────────────────────────────────────
#
# Pending secrets are held in memory until confirmed; never written to DB until
# the user successfully verifies their first TOTP code.

import time as _time

_pending_mfa: dict[str, tuple[str, float]] = {}  # user_id → (secret, expiry_epoch)
_MFA_SETUP_TTL = 600  # 10 minutes to confirm


@router.get("/my/mfa/status")
async def mfa_status(request: Request):
    user = _require_auth(request)
    from ..users import get_user_by_id
    u = await get_user_by_id(user.id)
    return {"enabled": bool(u and u.get("totp_secret"))}


@router.post("/my/mfa/setup/begin")
async def mfa_setup_begin(request: Request):
    user = _require_auth(request)
    from ..auth import generate_totp_secret, make_totp_provisioning_uri, make_totp_qr_png_b64
    secret = generate_totp_secret()
    _pending_mfa[user.id] = (secret, _time.time() + _MFA_SETUP_TTL)
    uri = make_totp_provisioning_uri(secret, user.username)
    qr = make_totp_qr_png_b64(uri)
    return {"qr": qr, "secret": secret}


class MfaConfirmIn(BaseModel):
    code: str


@router.post("/my/mfa/setup/confirm")
async def mfa_setup_confirm(request: Request, body: MfaConfirmIn):
    user = _require_auth(request)
    entry = _pending_mfa.get(user.id)
    if not entry or _time.time() > entry[1]:
        _pending_mfa.pop(user.id, None)
        raise HTTPException(status_code=400, detail="Setup session expired. Please start again.")
    secret, _ = entry
    from ..auth import verify_totp
    if not verify_totp(secret, body.code.strip()):
        raise HTTPException(status_code=400, detail="Invalid code. Try again.")
    from ..users import update_user
    await update_user(user.id, totp_secret=secret)
    _pending_mfa.pop(user.id, None)
    return {"ok": True}


class MfaDisableIn(BaseModel):
    password: str
    code: str


@router.delete("/my/mfa/disable")
async def mfa_disable(request: Request, body: MfaDisableIn):
    user = _require_auth(request)
    from ..auth import verify_password, verify_totp
    from ..users import get_user_by_username, update_user
    full = await get_user_by_username(user.username)
    if not full or not verify_password(body.password, full["password_hash"]):
        raise HTTPException(status_code=400, detail="Password is incorrect")
    if not full.get("totp_secret"):
        raise HTTPException(status_code=400, detail="MFA is not enabled")
    if not verify_totp(full["totp_secret"], body.code.strip()):
        raise HTTPException(status_code=400, detail="Invalid TOTP code")
    await update_user(user.id, totp_secret=None)
    return {"ok": True}


@router.delete("/users/{user_id}/mfa")
async def admin_clear_mfa(request: Request, user_id: str):
    _require_admin(request)
    from ..users import get_user_by_id, update_user
    if not await get_user_by_id(user_id):
        raise HTTPException(status_code=404, detail="User not found")
    await update_user(user_id, totp_secret=None)
    return {"ok": True}


# ── Theme ──────────────────────────────────────────────────────────────────────

@router.get("/my/theme")
async def get_my_theme(request: Request):
    user = _require_auth(request)
    from ..web.themes import DEFAULT_THEME, theme_list
    theme_id = await _user_settings_store.get(user.id, "theme") or DEFAULT_THEME
    return {"active": theme_id, "themes": theme_list()}


class ThemeIn(BaseModel):
    theme_id: str


@router.post("/my/theme")
async def set_my_theme(request: Request, body: ThemeIn):
    user = _require_auth(request)
    from ..web.themes import THEMES, DEFAULT_THEME
    if body.theme_id not in THEMES:
        raise HTTPException(status_code=400, detail="Unknown theme")
    if body.theme_id == DEFAULT_THEME:
        await _user_settings_store.delete(user.id, "theme")
    else:
        await _user_settings_store.set(user.id, "theme", body.theme_id)
    return {"ok": True}


# ── User profile (display name) ────────────────────────────────────────────────

@router.get("/my/profile")
async def get_my_profile(request: Request):
    user = _require_auth(request)
    from ..users import get_user_by_id
    u = await get_user_by_id(user.id)
    if not u:
        raise HTTPException(status_code=404, detail="User not found")
    return {
        "username": u["username"],
        "email": u.get("email") or "",
        "display_name": u.get("display_name") or "",
    }


class ProfileUpdateIn(BaseModel):
    display_name: str = ""


@router.post("/my/profile")
async def update_my_profile(request: Request, body: ProfileUpdateIn):
    user = _require_auth(request)
    from ..users import update_user
    await update_user(user.id, display_name=body.display_name.strip() or None)
    return {"ok": True}


# ── Webhook endpoints (inbound triggers) ──────────────────────────────────────

class WebhookEndpointIn(BaseModel):
    name: str
    agent_id: str = ""
    description: str = ""
    allow_get: bool = True


class WebhookEndpointUpdate(BaseModel):
    name: Optional[str] = None
    agent_id: Optional[str] = None
    description: Optional[str] = None
    allow_get: Optional[bool] = None
    enabled: Optional[bool] = None


@router.get("/webhooks")
async def list_webhooks(request: Request):
    _require_admin(request)
    from ..webhooks.endpoints import list_endpoints
    return await list_endpoints()


@router.post("/webhooks", status_code=201)
async def create_webhook(request: Request, body: WebhookEndpointIn):
    _require_admin(request)
    from ..webhooks.endpoints import create_endpoint
    if not body.name.strip():
        raise HTTPException(status_code=400, detail="Name is required")
    ep = await create_endpoint(
        name=body.name.strip(),
        agent_id=body.agent_id or "",
        description=body.description,
        allow_get=body.allow_get,
    )
    return ep  # includes token — only time it's returned


@router.put("/webhooks/{endpoint_id}")
async def update_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate):
    _require_admin(request)
    from ..webhooks.endpoints import update_endpoint
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    ep = await update_endpoint(endpoint_id, **fields)
    if ep is None:
        raise HTTPException(status_code=404, detail="Webhook not found")
    return ep


@router.delete("/webhooks/{endpoint_id}")
async def delete_webhook(request: Request, endpoint_id: str):
    _require_admin(request)
    from ..webhooks.endpoints import delete_endpoint
    deleted = await delete_endpoint(endpoint_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Webhook not found")
    return {"ok": True}


@router.post("/webhooks/{endpoint_id}/rotate")
async def rotate_webhook_token(request: Request, endpoint_id: str):
    _require_admin(request)
    from ..webhooks.endpoints import get_endpoint, rotate_token
    ep = await get_endpoint(endpoint_id)
    if ep is None:
        raise HTTPException(status_code=404, detail="Webhook not found")
    new_token = await rotate_token(endpoint_id)
    return {"ok": True, "token": new_token}


# ── User-scoped webhook endpoints (non-admin) ─────────────────────────────────

@router.get("/my/webhooks")
async def list_my_webhooks(request: Request):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    from ..webhooks.endpoints import list_endpoints
    return await list_endpoints(owner_user_id=user_id)


@router.post("/my/webhooks", status_code=201)
async def create_my_webhook(request: Request, body: WebhookEndpointIn):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    from ..webhooks.endpoints import create_endpoint
    if not body.name.strip():
        raise HTTPException(status_code=400, detail="Name is required")
    ep = await create_endpoint(
        name=body.name.strip(),
        agent_id=body.agent_id or "",
        description=body.description,
        allow_get=body.allow_get,
        owner_user_id=user_id,
    )
    return ep  # includes token — only time it's returned


@router.put("/my/webhooks/{endpoint_id}")
async def update_my_webhook(request: Request, endpoint_id: str, body: WebhookEndpointUpdate):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    from ..webhooks.endpoints import update_endpoint, get_endpoint
    ep = await get_endpoint(endpoint_id, owner_user_id=user_id)
    if ep is None:
        raise HTTPException(status_code=404, detail="Webhook not found")
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    updated = await update_endpoint(endpoint_id, **fields)
    return updated


@router.delete("/my/webhooks/{endpoint_id}")
async def delete_my_webhook(request: Request, endpoint_id: str):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    from ..webhooks.endpoints import delete_endpoint
    deleted = await delete_endpoint(endpoint_id, owner_user_id=user_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Webhook not found")
    return {"ok": True}


@router.post("/my/webhooks/{endpoint_id}/rotate")
async def rotate_my_webhook_token(request: Request, endpoint_id: str):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    from ..webhooks.endpoints import get_endpoint, rotate_token
    ep = await get_endpoint(endpoint_id, owner_user_id=user_id)
    if ep is None:
        raise HTTPException(status_code=404, detail="Webhook not found")
    new_token = await rotate_token(endpoint_id)
    return {"ok": True, "token": new_token}


# ── Webhook targets (outbound) ────────────────────────────────────────────────

class WebhookTargetIn(BaseModel):
    name: str
    url: str
    secret_header: str = ""


class WebhookTargetUpdate(BaseModel):
    name: Optional[str] = None
    url: Optional[str] = None
    secret_header: Optional[str] = None
    enabled: Optional[bool] = None


@router.get("/webhook-targets")
async def list_webhook_targets(request: Request):
    _require_admin(request)
    from ..database import get_pool
    pool = await get_pool()
    rows = await pool.fetch("SELECT * FROM webhook_targets ORDER BY name")
    return [dict(r) for r in rows]


@router.post("/webhook-targets", status_code=201)
async def create_webhook_target(request: Request, body: WebhookTargetIn):
    _require_admin(request)
    if not body.name.strip():
        raise HTTPException(status_code=400, detail="Name is required")
    if not body.url.strip():
        raise HTTPException(status_code=400, detail="URL is required")
    from ..database import get_pool
    from datetime import datetime, timezone
    pool = await get_pool()
    now = datetime.now(timezone.utc).isoformat()
    try:
        row = await pool.fetchrow(
            """
            INSERT INTO webhook_targets (name, url, secret_header, created_at)
            VALUES ($1, $2, $3, $4) RETURNING *
            """,
            body.name.strip(), body.url.strip(), body.secret_header or None,
            now,
        )
    except Exception as e:
        if "unique" in str(e).lower():
            raise HTTPException(status_code=409, detail="A target with that name already exists")
        raise
    return dict(row)


@router.put("/webhook-targets/{target_id}")
async def update_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate):
    _require_admin(request)
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    if not fields:
        raise HTTPException(status_code=400, detail="No fields to update")
    from ..database import get_pool
    pool = await get_pool()
    set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields))
    await pool.execute(
        f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid",
        target_id, *fields.values(),
    )
    row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id)
    if not row:
        raise HTTPException(status_code=404, detail="Target not found")
    return dict(row)


@router.delete("/webhook-targets/{target_id}")
async def delete_webhook_target(request: Request, target_id: str):
    _require_admin(request)
    from ..database import get_pool, _rowcount
    pool = await get_pool()
    status = await pool.execute(
        "DELETE FROM webhook_targets WHERE id = $1::uuid", target_id
    )
    if _rowcount(status) == 0:
        raise HTTPException(status_code=404, detail="Target not found")
    return {"ok": True}


# ── User-scoped webhook targets (non-admin) ───────────────────────────────────

@router.get("/my/webhook-targets")
async def list_my_webhook_targets(request: Request):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    from ..database import get_pool
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT * FROM webhook_targets WHERE owner_user_id = $1 ORDER BY name", user_id
    )
    return [dict(r) for r in rows]


@router.post("/my/webhook-targets", status_code=201)
async def create_my_webhook_target(request: Request, body: WebhookTargetIn):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    if not body.name.strip():
        raise HTTPException(status_code=400, detail="Name is required")
    if not body.url.strip():
        raise HTTPException(status_code=400, detail="URL is required")
    from ..database import get_pool
    from datetime import datetime, timezone
    pool = await get_pool()
    now = datetime.now(timezone.utc).isoformat()
    try:
        row = await pool.fetchrow(
            """
            INSERT INTO webhook_targets (name, url, secret_header, owner_user_id, created_at)
            VALUES ($1, $2, $3, $4, $5) RETURNING *
            """,
            body.name.strip(), body.url.strip(), body.secret_header or None, user_id, now,
        )
    except Exception as e:
        if "unique" in str(e).lower():
            raise HTTPException(status_code=409, detail="A target with that name already exists")
        raise
    return dict(row)


@router.put("/my/webhook-targets/{target_id}")
async def update_my_webhook_target(request: Request, target_id: str, body: WebhookTargetUpdate):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    if not fields:
        raise HTTPException(status_code=400, detail="No fields to update")
    from ..database import get_pool
    pool = await get_pool()
    # Verify ownership first
    existing = await pool.fetchrow(
        "SELECT id FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2",
        target_id, user_id,
    )
    if not existing:
        raise HTTPException(status_code=404, detail="Target not found")
    set_clauses = ", ".join(f"{k} = ${i + 2}" for i, k in enumerate(fields))
    await pool.execute(
        f"UPDATE webhook_targets SET {set_clauses} WHERE id = $1::uuid",
        target_id, *fields.values(),
    )
    row = await pool.fetchrow("SELECT * FROM webhook_targets WHERE id = $1::uuid", target_id)
    return dict(row)


@router.delete("/my/webhook-targets/{target_id}")
async def delete_my_webhook_target(request: Request, target_id: str):
    _require_auth(request)
    user_id = request.state.current_user["id"]
    from ..database import get_pool, _rowcount
    pool = await get_pool()
    status = await pool.execute(
        "DELETE FROM webhook_targets WHERE id = $1::uuid AND owner_user_id = $2",
        target_id, user_id,
    )
    if _rowcount(status) == 0:
        raise HTTPException(status_code=404, detail="Target not found")
    return {"ok": True}


# ── Page Change Monitors ───────────────────────────────────────────────────────

class WatchedPageIn(BaseModel):
    name: str
    url: str
    schedule: str = "0 * * * *"
    css_selector: Optional[str] = None
    agent_id: Optional[str] = None
    notification_mode: str = "agent"


class WatchedPageUpdate(BaseModel):
    name: Optional[str] = None
    url: Optional[str] = None
    schedule: Optional[str] = None
    css_selector: Optional[str] = None
    agent_id: Optional[str] = None
    notification_mode: Optional[str] = None
    enabled: Optional[bool] = None


@router.get("/watched-pages")
async def list_watched_pages(request: Request):
    user = _require_auth(request)
    from ..monitors.store import list_watched_pages as _list
    owner = None if user.is_admin else user.id
    return await _list(owner_user_id=owner)


@router.post("/watched-pages", status_code=201)
async def create_watched_page(request: Request, body: WatchedPageIn):
    user = _require_auth(request)
    if not body.name.strip() or not body.url.strip():
        raise HTTPException(status_code=400, detail="Name and URL are required")
    from ..monitors.store import create_watched_page as _create
    from ..monitors.page_monitor import page_monitor
    page = await _create(
        name=body.name.strip(),
        url=body.url.strip(),
        schedule=body.schedule,
        css_selector=body.css_selector or None,
        agent_id=body.agent_id or None,
        notification_mode=body.notification_mode,
        owner_user_id=user.id,
    )
    page_monitor.reschedule(page)
    return page


@router.put("/watched-pages/{page_id}")
async def update_watched_page(request: Request, page_id: str, body: WatchedPageUpdate):
    user = _require_auth(request)
    from ..monitors.store import get_watched_page, update_watched_page as _update
    page = await get_watched_page(page_id)
    if not page:
        raise HTTPException(status_code=404, detail="Page not found")
    if not user.is_admin and str(page.get("owner_user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Page not found")
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    updated = await _update(page_id, **fields)
    from ..monitors.page_monitor import page_monitor
    page_monitor.reschedule(updated)
    return updated


@router.delete("/watched-pages/{page_id}")
async def delete_watched_page(request: Request, page_id: str):
    user = _require_auth(request)
    from ..monitors.store import get_watched_page, delete_watched_page as _delete
    page = await get_watched_page(page_id)
    if not page:
        raise HTTPException(status_code=404, detail="Page not found")
    if not user.is_admin and str(page.get("owner_user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Page not found")
    await _delete(page_id)
    from ..monitors.page_monitor import page_monitor
    page_monitor.remove(page_id)
    return {"ok": True}


@router.post("/watched-pages/{page_id}/check-now")
async def check_page_now(request: Request, page_id: str):
    user = _require_auth(request)
    from ..monitors.store import get_watched_page
    page = await get_watched_page(page_id)
    if not page:
        raise HTTPException(status_code=404, detail="Page not found")
    if not user.is_admin and str(page.get("owner_user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Page not found")
    from ..monitors.page_monitor import page_monitor
    result = await page_monitor.check_now(page_id)
    return result


# ── RSS Feed Monitors ──────────────────────────────────────────────────────────

class RssFeedIn(BaseModel):
    name: str
    url: str
    schedule: str = "0 */4 * * *"
    agent_id: Optional[str] = None
    notification_mode: str = "agent"
    max_items_per_run: int = 5


class RssFeedUpdate(BaseModel):
    name: Optional[str] = None
    url: Optional[str] = None
    schedule: Optional[str] = None
    agent_id: Optional[str] = None
    notification_mode: Optional[str] = None
    max_items_per_run: Optional[int] = None
    enabled: Optional[bool] = None


@router.get("/rss-feeds")
async def list_rss_feeds(request: Request):
    user = _require_auth(request)
    from ..monitors.store import list_rss_feeds as _list
    owner = None if user.is_admin else user.id
    return await _list(owner_user_id=owner)


@router.post("/rss-feeds", status_code=201)
async def create_rss_feed(request: Request, body: RssFeedIn):
    user = _require_auth(request)
    if not body.name.strip() or not body.url.strip():
        raise HTTPException(status_code=400, detail="Name and URL are required")
    from ..monitors.store import create_rss_feed as _create
    from ..monitors.rss_monitor import rss_monitor
    feed = await _create(
        name=body.name.strip(),
        url=body.url.strip(),
        schedule=body.schedule,
        agent_id=body.agent_id or None,
        notification_mode=body.notification_mode,
        max_items_per_run=body.max_items_per_run,
        owner_user_id=user.id,
    )
    rss_monitor.reschedule(feed)
    return feed


@router.put("/rss-feeds/{feed_id}")
async def update_rss_feed(request: Request, feed_id: str, body: RssFeedUpdate):
    user = _require_auth(request)
    from ..monitors.store import get_rss_feed, update_rss_feed as _update
    feed = await get_rss_feed(feed_id)
    if not feed:
        raise HTTPException(status_code=404, detail="Feed not found")
    if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Feed not found")
    fields = {k: v for k, v in body.model_dump().items() if v is not None}
    updated = await _update(feed_id, **fields)
    from ..monitors.rss_monitor import rss_monitor
    rss_monitor.reschedule(updated)
    return updated


@router.delete("/rss-feeds/{feed_id}")
async def delete_rss_feed(request: Request, feed_id: str):
    user = _require_auth(request)
    from ..monitors.store import get_rss_feed, delete_rss_feed as _delete
    feed = await get_rss_feed(feed_id)
    if not feed:
        raise HTTPException(status_code=404, detail="Feed not found")
    if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Feed not found")
    await _delete(feed_id)
    from ..monitors.rss_monitor import rss_monitor
    rss_monitor.remove(feed_id)
    return {"ok": True}


@router.post("/rss-feeds/{feed_id}/fetch-now")
async def fetch_feed_now(request: Request, feed_id: str):
    user = _require_auth(request)
    from ..monitors.store import get_rss_feed
    feed = await get_rss_feed(feed_id)
    if not feed:
        raise HTTPException(status_code=404, detail="Feed not found")
    if not user.is_admin and str(feed.get("owner_user_id")) != user.id:
        raise HTTPException(status_code=404, detail="Feed not found")
    from ..monitors.rss_monitor import rss_monitor
    result = await rss_monitor.fetch_now(feed_id)
    return result
