"""MCP preferences HTTP routes extracted from [mugi_backend._build_app](mugi_backend.py)."""
from __future__ import annotations

import asyncio
import json
import logging
import os
import tempfile
import threading
import time
from collections import deque
from collections.abc import Callable
from typing import Any
from urllib.parse import urlparse

from fastapi import Request as FRequest
from fastapi.responses import JSONResponse

# Verbatim content from mugi_backend._build_app (Slice 5); tests assert preset ids.
MCP_PRESETS: list[dict[str, Any]] = json.loads(
    '[{"id":"remote_http","title":"Remote MCP (streamable HTTP)","description":"Connect to a remote MCP server over URL + optional auth header.","template":{"mcpServers":{"{{SERVER_ID}}":{"url":"{{URL}}","headers":{"Authorization":"Bearer {{TOKEN}}"},"enabled":true}}}},{"id":"peekaboo_http","title":"Peekaboo-style (HTTP)","description":"HTTP transport template commonly used by Peekaboo integrations.","template":{"mcpServers":{"{{SERVER_ID}}":{"url":"{{URL}}","headers":{"Authorization":"Bearer {{TOKEN}}"},"enabled":true}}}},{"id":"peekaboo_stdio","title":"Peekaboo (stdio MCP)","description":"Spawn Peekaboo as an MCP server over stdio (recommended). Install: brew install steipete/tap/peekaboo. Set command to `which peekaboo` if Preflight cannot find the binary.","template":{"mcpServers":{"{{SERVER_ID}}":{"command":"peekaboo","args":["mcp"],"enabled":true}}}},{"id":"stdio_npx","title":"Stdio (npx command)","description":"Run an MCP server with npx.","template":{"mcpServers":{"{{SERVER_ID}}":{"command":"npx","args":["-y","{{PACKAGE_NAME}}"],"enabled":true}}}},{"id":"stdio_python","title":"Stdio (python module)","description":"Run an MCP server as a Python module.","template":{"mcpServers":{"{{SERVER_ID}}":{"command":"python","args":["-m","{{MODULE_NAME}}"],"enabled":true}}}}]'
)


def register_mcp_preferences_routes(
    fastapi_app: Any,
    *,
    load_app_prefs: Callable[[], dict[str, Any]],
    log: logging.Logger,
    max_config_bytes: int,
    discover_timeout_seconds: float,
    health_rate_limit_count: int,
    health_rate_limit_window_seconds: float,
    health_rate_limit_state: dict[str, deque[float]],
    health_rate_limit_lock: threading.Lock,
) -> None:
    """Register /preferences/mcp/* routes."""

    def _read_mcp_config_strict():
        from mcp_bridge.config import MCP_CONFIG_PATH
        if not os.path.isfile(MCP_CONFIG_PATH):
            return {"mcpServers": {}}, None
        try:
            with open(MCP_CONFIG_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
            if not isinstance(data, dict):
                return None, "invalid JSON on disk"
            return data, None
        except Exception:
            return None, "invalid JSON on disk"

    def _resolve_import_path(path_raw: str):
        from pathlib import Path

        p = (path_raw or "").strip()
        if not p:
            return None, "path is required"
        expanded = os.path.expanduser(p)
        resolved = os.path.realpath(expanded)
        home_real = os.path.realpath(os.path.expanduser("~"))
        allowed_prefix = home_real + os.sep
        if resolved != home_real and not resolved.startswith(allowed_prefix):
            return None, "path must be under user home directory"
        if not os.path.isfile(resolved):
            return None, "path must point to an existing file"
        if Path(resolved).suffix.lower() != ".json":
            return None, "path must point to a .json file"
        return resolved, None

    def _check_mcp_health_rate_limit(req: FRequest):
        client_host = (getattr(req.client, "host", "") or "unknown").strip()
        key = f"{client_host}:{req.url.path}"
        now = time.monotonic()
        with health_rate_limit_lock:
            q = health_rate_limit_state.setdefault(key, deque())
            while q and (now - q[0]) > health_rate_limit_window_seconds:
                q.popleft()
            if len(q) >= health_rate_limit_count:
                return JSONResponse(content={"error": "rate limited"}, status_code=429)
            q.append(now)
        return None

    def _build_preflight_path() -> str:
        try:
            from tools.mugi_brew_bootstrap import get_mugi_homebrew_bin_dir
            mugi_bin = get_mugi_homebrew_bin_dir()
        except Exception:
            mugi_bin = ""
        candidates = [
            mugi_bin.strip(),
            "/opt/homebrew/bin",
            "/usr/local/bin",
            "/usr/bin",
            "/bin",
        ]
        env_path = os.environ.get("PATH", "")
        if env_path:
            candidates.extend(env_path.split(os.pathsep))
        out: list[str] = []
        seen: set[str] = set()
        for raw in candidates:
            item = (raw or "").strip()
            if not item or item in seen:
                continue
            seen.add(item)
            out.append(item)
        return os.pathsep.join(out)

    def _classify_transport(server_obj: dict[str, Any]) -> str:
        return "http" if bool(server_obj.get("url")) else "stdio"

    def _mcp_preflight_rows(cfg: dict[str, Any], target_ids: list[str]) -> list[dict[str, Any]]:
        import shutil

        servers_obj = cfg.get("mcpServers")
        servers = servers_obj if isinstance(servers_obj, dict) else {}
        rich_path = _build_preflight_path()
        rows: list[dict[str, Any]] = []
        for sid in target_ids:
            raw = servers.get(sid)
            if not isinstance(raw, dict):
                rows.append(
                    {
                        "id": sid,
                        "transport": "unknown",
                        "ok": False,
                        "issues": [{"code": "server_not_found", "field": "server_id", "message": "Server ID not found in config."}],
                    }
                )
                continue
            issues: list[dict[str, str]] = []
            transport = _classify_transport(raw)
            if transport == "http":
                url = str(raw.get("url") or "").strip()
                if not url:
                    issues.append({"code": "invalid_url", "field": "url", "message": "URL is required for HTTP transport."})
                else:
                    parsed = urlparse(url)
                    if parsed.scheme not in ("http", "https"):
                        issues.append(
                            {"code": "invalid_url", "field": "url", "message": "URL scheme must be http or https."}
                        )
                    if not parsed.netloc:
                        issues.append({"code": "invalid_url", "field": "url", "message": "URL host is required."})
            else:
                command = str(raw.get("command") or "").strip()
                if not command:
                    issues.append(
                        {"code": "missing_command", "field": "command", "message": "Command is required for stdio transport."}
                    )
                else:
                    expanded = os.path.expanduser(command)
                    found_path = ""
                    if os.path.isabs(expanded) or "/" in command:
                        if os.path.isfile(expanded):
                            found_path = expanded
                        else:
                            issues.append(
                                {
                                    "code": "command_not_found",
                                    "field": "command",
                                    "message": f"Command path does not exist: {command}",
                                }
                            )
                    else:
                        resolved = shutil.which(command, path=rich_path)
                        if resolved:
                            found_path = resolved
                        else:
                            issues.append(
                                {
                                    "code": "command_not_found",
                                    "field": "command",
                                    "message": f"Command '{command}' is not on PATH.",
                                }
                            )
                    if found_path and not os.access(found_path, os.X_OK):
                        issues.append(
                            {
                                "code": "command_not_executable",
                                "field": "command",
                                "message": f"Command is not executable: {found_path}",
                            }
                        )
                cwd = str(raw.get("cwd") or "").strip()
                if cwd:
                    expanded_cwd = os.path.expanduser(cwd)
                    if not os.path.isdir(expanded_cwd):
                        issues.append(
                            {
                                "code": "cwd_not_dir",
                                "field": "cwd",
                                "message": f"Working directory does not exist: {cwd}",
                            }
                        )
            rows.append({"id": sid, "transport": transport, "ok": not bool(issues), "issues": issues})
        return rows

    def _build_mcp_health_rows(
        wrappers: list,
        errors: list[dict[str, str]],
        server_ids: list[str],
    ) -> tuple[dict[str, list[str]], list[dict[str, Any]]]:
        tools_by_server: dict[str, list[str]] = {}
        for wrapper in wrappers or []:
            name = getattr(wrapper, "name", "") or ""
            server_id = getattr(wrapper, "_server_id", "") or ""
            tool_name = getattr(wrapper, "_tool_name", "") or ""
            if not server_id and name.startswith("mcp__"):
                parts = name.split("__", 2)
                if len(parts) == 3:
                    server_id = parts[1]
                    tool_name = parts[2]
            if not server_id:
                continue
            tools_by_server.setdefault(server_id, []).append(tool_name or name)
        for sid in list(tools_by_server.keys()):
            tools_by_server[sid] = sorted({t for t in tools_by_server[sid] if t})
        err_map = {str(e.get("server_id") or ""): str(e.get("error") or "") for e in (errors or [])}
        rows: list[dict[str, Any]] = []
        for sid in server_ids:
            err = err_map.get(sid, "")
            tool_count = len(tools_by_server.get(sid, []))
            rows.append(
                {
                    "id": sid,
                    "reachable": not bool(err),
                    "tool_count": tool_count,
                    "error": err or None,
                }
            )
        return tools_by_server, rows

    @fastapi_app.post("/preferences/mcp/preflight")
    async def mcp_preflight(req: FRequest):
        from mcp_bridge.config import iter_enabled_servers_from_config, validate_mcp_config_dict
        from mcp_bridge.mcp_merge import parse_mcp_root, redact_sensitive

        rate_limited = _check_mcp_health_rate_limit(req)
        if rate_limited is not None:
            return rate_limited
        raw = await req.body()
        if len(raw) > max_config_bytes:
            return JSONResponse(content={"error": "config too large"}, status_code=413)
        try:
            payload = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw or "{}"))
        except Exception:
            return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)
        if not isinstance(payload, dict):
            return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)
        log.debug("mcp_preflight request payload=%s", json.dumps(redact_sensitive(payload), ensure_ascii=False))
        cfg_obj = payload.get("config", payload)
        try:
            cfg = parse_mcp_root(cfg_obj)
        except ValueError as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=400)
        err = validate_mcp_config_dict(cfg)
        if err:
            return JSONResponse(content={"error": err}, status_code=400)
        requested_ids = payload.get("server_ids")
        if isinstance(requested_ids, list):
            target_ids = [str(v) for v in requested_ids if isinstance(v, str)]
        else:
            target_ids = [sid for sid, _ in iter_enabled_servers_from_config(cfg)]
        rows = _mcp_preflight_rows(cfg, target_ids)
        return JSONResponse(content={"ok": True, "servers": rows})

    @fastapi_app.get("/preferences/mcp/status")
    async def mcp_status():
        from mcp_bridge import cached_tool_count
        from mcp_bridge.config import MCP_CONFIG_PATH, sdk_installed, summarize_servers

        config_exists = os.path.isfile(MCP_CONFIG_PATH)
        _, cfg_err = _read_mcp_config_strict()
        prefs = load_app_prefs()
        return JSONResponse(
            content={
                "config_path": MCP_CONFIG_PATH,
                "config_exists": config_exists,
                "config_valid": cfg_err is None,
                "sdk_installed": sdk_installed(),
                "servers": summarize_servers(),
                "cached_tool_count": cached_tool_count(),
                "mcp_enabled": prefs.get("mcp_enabled", True) is not False,
            }
        )

    @fastapi_app.get("/preferences/mcp/config")
    async def mcp_get_config():
        data, err = _read_mcp_config_strict()
        if err:
            return JSONResponse(content={"error": err}, status_code=500)
        return JSONResponse(content=data or {"mcpServers": {}})

    @fastapi_app.put("/preferences/mcp/config")
    async def mcp_put_config(req: FRequest):
        from mcp_bridge import invalidate_discovery_cache
        from mcp_bridge.adapters import DISCOVERY_LOCK
        from mcp_bridge.config import MCP_CONFIG_PATH, validate_mcp_config_dict
        from mcp_bridge.session_manager import SESSION_MANAGER

        raw = await req.body()
        if len(raw) > max_config_bytes:
            return JSONResponse(content={"error": "config too large"}, status_code=413)
        try:
            payload = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw or "{}"))
        except Exception:
            return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)

        err = validate_mcp_config_dict(payload)
        if err:
            return JSONResponse(content={"error": err}, status_code=400)

        config_dir = os.path.dirname(MCP_CONFIG_PATH)
        os.makedirs(config_dir, exist_ok=True)
        tmp_path: str | None = None
        try:
            with DISCOVERY_LOCK:
                with tempfile.NamedTemporaryFile(
                    mode="w",
                    encoding="utf-8",
                    prefix=".mcp_servers.json.tmp.",
                    dir=config_dir,
                    delete=False,
                ) as tf:
                    tmp_path = tf.name
                    json.dump(payload, tf, ensure_ascii=False, indent=2)
                    tf.flush()
                    os.fsync(tf.fileno())
                os.replace(tmp_path, MCP_CONFIG_PATH)
                invalidate_discovery_cache()
                SESSION_MANAGER.clear_server_cache()
            return JSONResponse(content={"ok": True})
        except Exception:
            return JSONResponse(content={"error": "could not save config"}, status_code=500)
        finally:
            if tmp_path and os.path.isfile(tmp_path):
                try:
                    os.remove(tmp_path)
                except Exception:
                    pass

    @fastapi_app.post("/preferences/mcp/import/preview")
    async def mcp_import_preview(req: FRequest):
        from mcp_bridge.config import validate_mcp_config_dict
        from mcp_bridge.mcp_merge import (
            collect_server_warnings,
            collect_portability_warnings,
            merge_mcp_servers,
            parse_mcp_root,
            redact_sensitive,
            read_mcp_file,
        )

        raw = await req.body()
        if len(raw) > max_config_bytes:
            return JSONResponse(content={"error": "config too large"}, status_code=413)
        try:
            payload = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw or "{}"))
        except Exception:
            return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)
        if not isinstance(payload, dict):
            return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)
        log.debug("mcp_import_preview request payload=%s", json.dumps(redact_sensitive(payload), ensure_ascii=False))

        on_conflict = str(payload.get("on_conflict") or "rename").strip().lower()
        incoming = None
        path_used = None
        if isinstance(payload.get("path"), str):
            path_used, path_err = _resolve_import_path(payload.get("path") or "")
            if path_err:
                return JSONResponse(content={"error": path_err}, status_code=400)
            try:
                incoming = read_mcp_file(path_used, max_bytes=max_config_bytes)
            except ValueError as exc:
                msg = str(exc)
                code = 413 if msg == "config too large" else 400
                return JSONResponse(content={"error": msg}, status_code=code)
        elif "json" in payload:
            try:
                incoming = parse_mcp_root(payload.get("json"))
            except ValueError as exc:
                return JSONResponse(content={"error": str(exc)}, status_code=400)
        else:
            return JSONResponse(content={"error": "body must include 'path' or 'json'"}, status_code=400)

        current, current_err = _read_mcp_config_strict()
        if current_err:
            return JSONResponse(content={"error": current_err}, status_code=500)
        base = current if isinstance(current, dict) else {"mcpServers": {}}
        try:
            merged, conflicts = merge_mcp_servers(base, incoming, on_conflict=on_conflict)
        except ValueError as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=400)

        validation_error = validate_mcp_config_dict(merged)
        warnings = (
            collect_server_warnings(incoming)
            + collect_server_warnings(merged)
            + collect_portability_warnings(incoming)
        )
        if not incoming.get("mcpServers"):
            warnings.append("incoming config has no MCP servers")
        base_servers = base.get("mcpServers") if isinstance(base, dict) else {}
        incoming_servers = incoming.get("mcpServers") if isinstance(incoming, dict) else {}
        if isinstance(base_servers, dict) and isinstance(incoming_servers, dict):
            incoming_ids = [sid for sid in incoming_servers.keys() if isinstance(sid, str)]
            new_ids = [sid for sid in incoming_ids if sid not in base_servers]
            if not new_ids:
                warnings.append("import produced no new servers to add")
        return JSONResponse(
            content={
                "ok": True,
                "merged": merged,
                "conflicts": conflicts,
                "validation_error": validation_error,
                "warnings": sorted(set(warnings)),
                "path_used": path_used,
            }
        )

    @fastapi_app.get("/preferences/mcp/presets")
    async def mcp_presets():
        return JSONResponse(content={"presets": MCP_PRESETS})

    @fastapi_app.post("/preferences/mcp/health")
    async def mcp_health(req: FRequest):
        from mcp_bridge.adapters import discover_tools
        from mcp_bridge.config import iter_enabled_servers
        rate_limited = _check_mcp_health_rate_limit(req)
        if rate_limited is not None:
            return rate_limited

        requested_ids: list[str] | None = None
        raw = await req.body()
        if raw:
            if len(raw) > max_config_bytes:
                return JSONResponse(content={"error": "config too large"}, status_code=413)
            try:
                body = json.loads(raw.decode("utf-8"))
            except Exception:
                return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)
            if isinstance(body, dict) and isinstance(body.get("server_ids"), list):
                requested_ids = [str(v) for v in body["server_ids"] if isinstance(v, str)]

        enabled_server_ids = [sid for sid, _ in iter_enabled_servers()]
        target_ids = requested_ids if requested_ids is not None else enabled_server_ids
        try:
            wrappers, _schemas, errors = await asyncio.wait_for(
                asyncio.to_thread(discover_tools),
                timeout=discover_timeout_seconds,
            )
        except asyncio.TimeoutError:
            return JSONResponse(content={"error": "health check timed out"}, status_code=504)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

        tools_by_server, rows = _build_mcp_health_rows(wrappers, errors or [], target_ids)
        return JSONResponse(content={"ok": True, "servers": rows, "tools_by_server": tools_by_server, "errors": errors or []})

    @fastapi_app.post("/preferences/mcp/health_body")
    async def mcp_health_body(req: FRequest):
        from mcp_bridge.adapters import discover_tools_from_config
        from mcp_bridge.config import iter_enabled_servers_from_config, validate_mcp_config_dict
        from mcp_bridge.mcp_merge import parse_mcp_root, redact_sensitive
        rate_limited = _check_mcp_health_rate_limit(req)
        if rate_limited is not None:
            return rate_limited

        raw = await req.body()
        if len(raw) > max_config_bytes:
            return JSONResponse(content={"error": "config too large"}, status_code=413)
        try:
            payload = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw or "{}"))
        except Exception:
            return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)
        if not isinstance(payload, dict):
            return JSONResponse(content={"error": "invalid JSON body"}, status_code=400)
        log.debug("mcp_health_body request payload=%s", json.dumps(redact_sensitive(payload), ensure_ascii=False))
        cfg_obj = payload.get("config", payload)
        try:
            cfg = parse_mcp_root(cfg_obj)
        except ValueError as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=400)

        err = validate_mcp_config_dict(cfg)
        if err:
            return JSONResponse(content={"error": err}, status_code=400)

        requested_ids = payload.get("server_ids")
        if isinstance(requested_ids, list):
            target_ids = [str(v) for v in requested_ids if isinstance(v, str)]
        else:
            target_ids = [sid for sid, _ in iter_enabled_servers_from_config(cfg)]
        try:
            wrappers, _schemas, errors = await asyncio.wait_for(
                asyncio.to_thread(discover_tools_from_config, cfg),
                timeout=discover_timeout_seconds,
            )
        except asyncio.TimeoutError:
            return JSONResponse(content={"error": "health check timed out"}, status_code=504)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

        tools_by_server, rows = _build_mcp_health_rows(wrappers, errors or [], target_ids)
        return JSONResponse(content={"ok": True, "servers": rows, "tools_by_server": tools_by_server, "errors": errors or []})

    @fastapi_app.post("/preferences/mcp/discover")
    async def mcp_discover():
        from mcp_bridge.adapters import discover_tools
        try:
            wrappers, _schemas, errors = await asyncio.wait_for(
                asyncio.to_thread(discover_tools),
                timeout=discover_timeout_seconds,
            )
        except asyncio.TimeoutError:
            return JSONResponse(content={"error": "discovery timed out"}, status_code=504)
        except Exception as exc:
            return JSONResponse(content={"error": str(exc)}, status_code=500)

        tools_by_server: dict[str, list[str]] = {}
        for wrapper in wrappers or []:
            name = getattr(wrapper, "name", "") or ""
            server_id = getattr(wrapper, "_server_id", "") or ""
            tool_name = getattr(wrapper, "_tool_name", "") or ""
            if not server_id and name.startswith("mcp__"):
                parts = name.split("__", 2)
                if len(parts) == 3:
                    server_id = parts[1]
                    tool_name = parts[2]
            if not server_id:
                server_id = "_unknown"
            tools_by_server.setdefault(server_id, []).append(tool_name or name)
        for sid in list(tools_by_server.keys()):
            tools_by_server[sid] = sorted({t for t in tools_by_server[sid] if t})

        return JSONResponse(content={"ok": True, "tools_by_server": tools_by_server, "errors": errors or []})
